Compare commits

..

264 Commits

Author SHA1 Message Date
Patrick Buckley a8f6348f51 chore: bump version to 1.5.0 2026-04-29 00:16:57 -07:00
Patrick Buckley f24c6d6c73 docs(readme): refresh hero image to coordinator UX shot
Replaces the old mermaid-rendering shot with a coordinator session
mid-attention — parallel tool batches, judge-graded approval,
children + tasks side panels — which more accurately represents
what the platform does today.
2026-04-29 00:13:48 -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 64d5205dd6 perf(session): split metacognitive nudges out of the system message
The system-message developer block was rebuilt every turn with two
unstable inputs: minute-precision current_datetime in the middle of
the composed prefix, and _pending_nudge entries appended-then-cleared
at the bottom. Both invalidated prompt-cache reuse on Anthropic /
OpenAI for the entire prefix, every turn.

current_datetime now rounds to the top of the hour. Nudges no longer
ride on the system message at all — they drain through two channels:

- tool_error and repeat ride the existing tool-result <system-reminder>
  envelope via a new MetacognitiveAdvisory ToolAdvisory subtype, drained
  in _collect_advisories alongside GuardAdvisory and UserInterjection.
- correction, denial, resume, start, completion splice as
  <system-reminder> blocks at the trailing edge of the next user
  message via a new _splice_pending_user_advisories helper.

User content passes through escape_wrapper_tags before concatenation
so a user typing literal <system-reminder> tags cannot fabricate an
envelope; the same escape now runs on advisory.render() output inside
wrap_tool_result for defense-in-depth across all advisory types.

Cancel handlers (GenerationCancelled / KeyboardInterrupt / bare
Exception) now clear _pending_tool_advisories alongside the existing
_flush_queued_messages so a queued nudge from an aborted batch
cannot leak into the next generation.

Visibility ping ([metacognition: nudge injected — ...]) preserved at
both new attach points via a single _emit_nudge_ping helper.

Also adds a "Session kind" line (interactive | coordinator) to the
composed Session Context so the model can see which manager hosts
its session.

Tests: 4847 passing (+7 new in TestMetacognitiveBuffers and
test_tool_advisory). ruff + mypy clean.
2026-04-28 19:51:00 -07:00
Patrick Buckley 08c6eeb1e5 fix(coord): close three copilot review gaps on PR 446
Copilot review on c5fe3e7 flagged three follow-ups:

1. tasks-write batch order was scheduler-dependent.  Prior comment
   claimed the result was "deterministic against the input set even
   if the dispatch order isn't" — true for the SET of tasks, but
   ``tasks_add`` appends under a per-ws lock, so the FINAL list
   ordering (and order-derived timestamps) varied with whichever
   thread happened to acquire the lock first.  Fix: when a batch
   contains any tasks-write, the dispatcher runs the WHOLE batch
   serially in input order.  Other batches stay parallel.

2. ``tasks_add`` test stubs were the wrong shape.  ``CoordinatorClient.
   tasks_add()`` returns the task dict directly with top-level
   ``id`` / ``title`` / ``status`` / ``child_ws_id`` / ``created`` /
   ``updated`` — the previous stubs wrapped it as ``{"ok": True,
   "task": {...}}`` and weakened the tests since
   ``_exec_tasks``'s summary path reads ``result.get("id")`` and
   would have seen ``"?"`` against the wrong shape.  Both stubs
   updated to match the real contract.

3. New regression test pins the input-order property on tasks-write
   batches.  ``test_tasks_writes_run_in_input_order`` captures the
   ``tasks_add`` call sequence and asserts it matches the model's
   emit order exactly — pre-fix this would be scheduler-dependent.
   Plus ``test_tasks_writes_serial_when_mixed_with_non_tasks_siblings``
   pins the same property when the batch interleaves a
   ``list_nodes`` call with two ``tasks(add)`` calls.

Tests: 4820 pass (+2 net since the prior PR 446 push).  Ruff +
mypy clean.
2026-04-28 14:23:32 -07:00
Patrick Buckley f6fbf2d85b fix(coord): list_nodes accepts flat-arg filters too
Operator's harness shakedown found list_nodes filters silently
ignored on every call:

    list_nodes(os="Linux")            → returns ALL 10 nodes
    list_nodes(has_gpu=true)          → returns ALL 10 nodes
    list_nodes(memory_gb=751)         → returns ALL 10 nodes (no
                                         node has 751 GiB; should be 0)

Storage filter pipeline is fine (pinned by an existing
test_list_nodes_filter_uses_natural_value_not_quoted).  The bug is
upstream in ``_prepare_list_nodes``: it only honoured
``args["filters"]`` (the canonical nested shape).  Several models
drop the nesting and emit each filter as a top-level kwarg —
``list_nodes(os="Linux")`` instead of ``list_nodes(filters={"os":
"Linux"})`` — and the strict prepare silently degraded those calls
to "no filter" → full-cluster return.

Fix: any top-level kwarg that ISN'T one of the four reserved control
parameters (``filters``, ``limit``, ``include_network_detail``,
``include_inactive``) is now treated as a flat filter.  Nested entries
still win on key collision so the canonical shape stays
deterministic.  Tool description unchanged so well-behaved models
keep using ``filters={...}``; the relaxation is purely receiver-side.

Tests: 4818 pass (+5 net).  Five new tests pin both shapes plus the
collision-precedence rule and the prepare→exec wiring.  Ruff + mypy
clean.
2026-04-28 14:23:32 -07:00
Patrick Buckley fca1ac3736 fix(coord): relax tasks parallel-batch rule to mixed read+write only
Operator observed the prior rule rejecting a natural decompose-the-
plan turn:

    [tasks(add×4), list_nodes, list_skills, list_workstreams]

The 4 tasks(add) calls landed (per-ws lock serialised them) but the
guard blanket-rejected EVERY tasks(...) regardless of what its
siblings actually were.  All-write batches converge under the
per-ws lock; all-read batches can't race.  The only genuinely-
hazardous shape is the read+write mix where tasks(list)
paralleled with tasks(add=...) inside ``run_one``'s
ThreadPoolExecutor has unspecified ordering and the read can land
on either side of the write.

The rule now scopes precisely:

  - All ``tasks`` writes in a batch — permitted.
  - All ``tasks`` reads in a batch — permitted.
  - ``tasks`` paralleled with non-``tasks`` siblings — permitted
    in either direction.  Non-tasks tools don't touch the tasks
    state, so there's no read-after-write surface.
  - ``tasks`` read AND ``tasks`` write in the same batch — REJECTED
    (still, because that IS the actual hazard).

Tests: 4813 pass (+4 net).  Six new tests pin the relaxation
(all-write OK, all-read OK, write+sibling OK, read+sibling OK,
non-tasks-only batch unaffected) and the one tightened rejection
case (read+write mixed in tasks specifically).  Ruff + mypy clean.
2026-04-28 14:23:32 -07:00
Patrick Buckley d0f5f50650 feat(node): auto-detect node capabilities via kernel interfaces (#445)
* feat(node): auto-detect node capabilities via kernel interfaces

Closes the operator-burden gap the harness shakedown surfaced — the
list_nodes capability/region/role filtering surface that nodes were
launching with empty.  Auto-detection runs at server startup and
populates ``node_metadata`` rows with sensible defaults that
operators can still override via the ``[metadata]`` section of
config.toml (operator-config writes win on the per-key upsert).

What's detected, all from kernel interfaces (no userspace binaries
on PATH — works the same way regardless of whether nvidia-smi /
rocm-smi / lspci is installed):

- ``gpu_count`` / ``gpu_vendor`` / ``gpu_vendors`` / ``gpus`` —
  walks ``/sys/class/drm/cardN/device/{vendor,device}`` and decodes
  PCI vendor IDs to friendly names (NVIDIA / AMD / Intel / Apple).
  Heterogeneous-GPU nodes get the first KNOWN vendor in the flat
  ``gpu_vendor`` key — never ``"unknown"`` when known vendors are
  present — so a coord filtering on ``gpu_vendor=nvidia`` matches
  nodes whose first card happened to be exotic.
- ``memory_gb`` — reads ``/proc/meminfo``, rounds GiB down so
  ``filters={"memory_gb": 32}`` doesn't match a 31.5 GiB node.
- ``cpu_model`` — first ``model name`` line from ``/proc/cpuinfo``.
- ``cloud_provider`` / ``cloud_region`` / ``cloud_zone`` /
  ``cloud_instance_type`` / ``cloud_instance_id`` — DMI sysfs
  identifies the cloud provider from BIOS/SMBIOS strings (no
  network call) and only THEN does the IMDS probe fire.  Baremetal
  hosts pay zero startup latency on the cloud path.

Hardening highlights:

- IMDS probes target the link-local IP literal ``169.254.169.254``
  for AWS, GCP, AND Azure — no DNS-resolvable hostname for any
  vendor, so a host with attacker-controlled DNS can't redirect
  the probe even when its DMI claims a cloud provider.
- Response bodies capped at 64 KiB on read; per-field strings
  capped at 256 chars and stripped of control characters before
  persistence.  Stops a hostile IMDS responder from spraying
  multi-megabyte / newline-injected payloads into ``node_metadata``
  and from there into coord-LLM ``list_nodes`` context.
- ``isinstance(doc, dict)`` guards on every JSON IMDS response so
  a non-conformant body (list / scalar / null) returns clean ``{}``
  instead of raising.
- ``collect_node_info()`` runs via ``asyncio.to_thread`` from the
  server's lifespan handler so the IMDS probe latency never blocks
  the event loop.
- GCP fans the three zone/machine-type/id probes concurrently so a
  misidentified host's worst case is one timeout window (~1 s)
  instead of three (~3 s).
- Operator opt-out via ``TURNSTONE_AUTO_CLOUD_METADATA=0`` skips the
  IMDS phase entirely; the DMI-derived ``cloud_provider`` still
  populates because that's a kernel interface.

Tests: 4807 pass (+11 net, 73 in test_node_info.py).  Ruff + mypy
clean on every modified file.  New tests pin the heterogeneous-GPU
flat-key fix, the IMDS hardening (non-dict JSON, control-char
sanitisation, body cap, per-field cap), and the GCP IP-literal
property.

* fix(node): filter synthetic display adapters + per-vendor GPU flags

PR review on 68cb0ab flagged two real issues with the GPU surface:

1. Hyper-V synthetic display adapter (vendor 0x1414, device 0x06)
   registers a /sys/class/drm/cardN entry on Linux but is NOT a
   compute GPU.  A CI runner reproduced this and came back with
   gpu_count=1 on a CPU-only VM.  Same hazard for AWS Nitro VGA,
   QEMU virtio-gpu, and any other hypervisor synthetic display
   adapter.  Fix: ``_detect_gpus`` filters DRM cards by PCI vendor
   against the GPU allow-list (NVIDIA / AMD / Intel / Apple); cards
   from other vendors are skipped entirely.  Operators with exotic
   accelerators that don't match any known vendor can still set
   ``gpu_count`` + the relevant flags via [metadata] config.

2. ``gpu_vendor`` (singular flat key) was sorted-alphabetical-first
   of the unique known vendors.  list_nodes() filtering does
   exact-equality JSON matching, so a mixed AMD+NVIDIA node ended
   up with ``gpu_vendor=amd`` and was invisible to a coord
   filtering ``gpu_vendor=nvidia``.  Fix: drop the singular key
   entirely; emit per-vendor booleans (``gpu_has_nvidia=true``,
   ``gpu_has_amd=true``) so multi-vendor nodes match EITHER vendor.
   Also add ``has_gpu=true`` for "any compute GPU at all" filtering.

Tests: 4809 pass (+2 net).  New / updated tests pin both behaviors:
- _detect_gpus: Hyper-V synthetic + arbitrary unknown-vendor card
  now filter out; mixed-known-and-unknown keeps only the known card.
- collect_node_info integration: multi-vendor node has both
  ``gpu_has_amd`` and ``gpu_has_nvidia`` set; ``gpu_vendor`` (singular)
  is asserted absent so a future regression that re-introduces it
  fails loudly.

Ruff + mypy clean.
2026-04-28 13:49:31 -07:00
Patrick Buckley 7d6b31e18a fix(coord): close gaps an operator's harness shakedown surfaced (#444)
* fix(coord): close gaps an operator's harness shakedown surfaced

Operator-driven shakedown of the coordinator tool surface flagged
five issues; this commit addresses all of them plus the review
findings against the initial fix.

1. Cancelled-mid-stream partial assistant content now carries a
   "[generation cancelled before completion]" marker.  Without it,
   ``inspect_workstream`` / ``wait_for_workstream`` callers and the
   next coord-LLM turn read the truncated text as a complete answer.
   ``_cancelled_partial_msg`` no longer ships ``_provider_content``
   (Anthropic would otherwise read that lane verbatim and bypass the
   marker; partial tool_use blocks could also leak through).

2. ``spawn_workstream`` / ``spawn_batch`` no longer surface the
   routing-proxy ``status`` field (always HTTP 200 on the success
   path).  The tool description claimed it was "lifecycle state at
   creation"; code that did ``if result["status"] == "idle"``
   silently never matched.  Lifecycle state lives on the workstream
   row — ``inspect_workstream`` is the read.  Tool JSON descriptions
   plus docs/coordinator-skills.md and docs/bulk-endpoints.md
   examples updated to match.

3. ``inspect_workstream`` not-found error string is bare ("workstream
   not found"); the structured ``ws_id`` field carries the queried
   id.  Pre-fix the error STRING echoed the id back at the caller
   who just sent it — redundant and out of step with the rest of the
   surface.  Cross-tenant + missing rows still return the same shape,
   preserving the existence-leak guarantee.

4. ``tasks(...)`` is now rejected when called in a parallel tool
   batch.  The prior shape relied on a docstring warning ("a list
   paralleled with writes can reflect pre-write state") that put
   cognitive overhead on every model invocation; turning the silent
   footgun into an explicit error means the model only thinks about
   the rule the moment it actually breaks it.  Warning dropped from
   the tasks tool description.  ``_PARALLEL_INCOMPATIBLE_TOOLS``
   constant in session.py is the extension point for any future
   tool with the same read-after-write hazard.

Plus the multi-stage code review's findings against the initial
fix (q-1 / q-2 docs drift, q-3 idiom, q-4 keys-assertion, q-5
duplicate guard) — all addressed in the same pass.

Tests: 4752 pass, +6 net since the pre-fix baseline.  Ruff + mypy
clean.  Three new tests pin the parallel-batch-rejection behaviour
on tasks (rejected when batched, runs alone, sibling tools
unaffected); existing cancel + spawn + inspect tests updated to
match the new shape.

* fix(coord): close two copilot review gaps on PR 444

Copilot review on PR 444 flagged two follow-ups:

1. Empty-content cancel divergence — when ``GenerationCancelled``
   races BEFORE the first content token, the prior shape skipped
   ``save_message`` and only appended an empty-content msg in
   memory.  In-memory and storage diverged: a rehydrate would see
   nothing in storage but the session would carry an empty
   assistant turn.  Both branches now persist; on the empty-content
   shape the marker becomes the entire message
   ("[generation cancelled before completion]") so storage matches
   the in-memory history.

2. Test stub cleanup — three new tests injected ``ui.approve_tools``
   via ad-hoc ``lambda + type: ignore[attr-defined]``.  Replaced
   with a permissive ``approve_tools`` method on ``_StubUI`` so the
   stub matches the SessionUI surface the dispatcher actually
   reads.  Tests that exercise approval pathways can still override
   per-instance.

Tests: 4752 pass.  Ruff + mypy clean.
2026-04-28 12:52:36 -07:00
Patrick Buckley 9a30530d41 feat(coord): surface child errors, isolate tool exceptions, add memory tool (#443)
* feat(coord): surface child errors, isolate tool exceptions, add memory tool

Closes four coordinator gaps identified during operator triage:

1. Child workstream errors now surface in inspect/wait. Worker-thread
   exception text is sanitized (URL userinfo masked, sk-/Bearer/ghp_/
   github_pat_/AKIA tokens redacted, capped at 1024 chars) and persisted
   to workstream_config.last_error before _emit_state("error") fires, so
   coord polling never sees state=error with a missing cause. The row
   is cleared on recovery transitions (idle/running) so a once-leaked
   exception body doesn't outlive the failure. inspect_workstream and
   wait_for_workstream return last_error for state=error rows; the
   wait surface prefers it over the assistant-tail walk.

2. Tool exceptions now return as tool_results with sibling-aware
   guidance. ChatSession._safe_prepare_tool wraps every per-call
   _prepare_tool invocation; a buggy preparer becomes an error item
   for that call only — sibling parallel tool_calls keep going,
   never orphaning the assistant message's tool_calls block.
   run_one's runtime exception path includes the exception class
   and a short note that other tool calls in the batch completed
   independently so the model can recover.

3. Memory tool exposed to coordinator with a coord-only scope.
   memory.json gains coordinator: true + interactive: true + per-kind
   kind_variants. Coord sessions see scope enum ["coordinator"] and
   an orchestration-flavored description; IC sessions see ["global",
   "workstream", "user"] and the existing flavor. Coord-scope rows
   are private to the coordinator session (children cannot read or
   write them), closing the cross-session prompt-injection lane that
   an adversarially-steered child would otherwise have. Coord
   visibility is also restricted to coord-scope only — coords no
   longer see global / workstream / user memories that belong to the
   user's interactive sessions.

4. Per-call exception isolation in tool batches. _safe_prepare_tool
   was previously the implicit shield; now it's an explicit method
   with documented invariants. KeyboardInterrupt / GenerationCancelled
   re-raise so the cooperative cancel path still works.

Other notable changes:
- LAST_ERROR_CONFIG_KEY + persist_last_error / clear_last_error /
  load_last_error / sanitize_error_text moved to turnstone.core.memory
  (the storage facade hub) — readers in coordinator_client.py import
  the constant.
- Memory scope tuples extracted to module constants
  _VALID_MEMORY_SCOPES and _IMPLICIT_SCOPE_WALK; seven inline
  duplicates collapsed.
- tools.py grows _apply_kind_variant for the per-kind tool surface;
  tools without kind_variants pass through unchanged (no spurious
  deep-copies).
- Session adds _coordinator_scope_id, _default_memory_scope,
  _implicit_scope_walk, and _record_fatal_error chokepoints so the
  worker-thread fatal path is one site rather than three.
- Removed duplicate on_error / on_state_change emits from
  session_routes.py and coordinator_adapter.py — session.send()'s
  _record_fatal_error owns the sequence now.

Tests: 4742 pass (no live), +30 net since the baseline. Ruff + mypy
clean on every modified production file.

* fix(coord): redact secrets in tool error paths via output_guard

Copilot review flagged two paths where ``str(exc)`` flowed back into
the model-facing tool_result without going through the credential-
redaction the new fatal-error path applies:

  - ``ChatSession._safe_prepare_tool``: a preparer-side exception
    becomes an error item whose ``error`` field embedded the raw
    exception text.
  - ``ChatSession._execute_tools.run_one``: a runtime tool exception
    became an ``Error executing X: <e>`` tool_result, again with
    the raw exception text.

Both now route through ``sanitize_error_text`` (sanitised log line +
sanitised tool_result), and ``sanitize_error_text`` itself was
refactored to delegate to ``output_guard.redact_credentials`` instead
of carrying its own parallel regex catalog — the audit log + post-tool
guard already use that pattern set, so the credential definition
stays in one place.

Also extended ``_RE_CONNECTION_STRING`` in ``output_guard`` to cover
``http(s)://user:pass@host`` so a misconfigured ``OPENAI_BASE_URL``
that lands in an httpx ``ConnectError.__str__`` is redacted by every
caller of ``redact_credentials`` (audit details, close-reason
persistence, last_error, the two tool error paths).  The
host (useful for triage) survives; only the password is replaced
with the standard ``[REDACTED:password]`` marker.

Tests: full suite (4745 pass), ruff + mypy clean.  Two new tests pin
the redaction behaviour in both tool error paths so a future refactor
can't drift back to leaking ``str(exc)`` verbatim.
2026-04-28 12:19:34 -07:00
Patrick Buckley 352a27915a feat(coord): per-coordinator status bar + richer history replay
Bring the coord dashboard toward parity with the interactive pane on
two operator-visible surfaces:

- Status bar pinned above the composer.  Same four cells as the
  interactive pane (model, token / context-window usage with effort
  suffix, tool calls this turn, conversation turn) driven by the
  same on_status SSE events.  ws-status-bar CSS hoisted from
  ui/static/style.css to shared_static/chat.css so both UIs read one
  copy.  StatusBar.paint helper extracted to
  shared_static/status_bar.js; both Pane.prototype.updateStatus and
  the new coord updateStatusBar delegate to it so warn/danger
  thresholds, prefix glyphs, and effort-suffix rules can't drift.
  CTX_WARN_PCT / CTX_DANGER_PCT now named constants on a single line.

- _coord_events_replay now yields the connected + status preamble
  via a shared session_replay_preamble helper in
  turnstone/core/session_replay.py.  _interactive_events_replay
  routes through the same helper so a future field add lands once.
  Coord still skips conversation history in the SSE replay (the
  dashboard fetches it via GET /history); only the status preamble
  is shared.

- History replay reconstructs tool calls.  Pre-fix, an assistant
  turn that only dispatched tools rendered as an empty bubble
  followed by raw tool-result text — the call's intent and
  parameters were lost on reload.  synthesizeHistoricalToolCall
  builds an appendToolCall-shaped item from the persisted
  function.name + function.arguments (special-casing bash so the
  shell line shows in the header).  Tool result rows now resolve
  their label from the matching tool_call_id instead of always
  printing "tool".

- onopen restores the tokens placeholder when no prior status was
  seen, so a transient SSE blip on a fresh coord doesn't leave the
  dim "Reconnecting…" copy stuck until the next live tick.

Tests: 4 new tests for the shared replay preamble (connected first,
status only when last_usage present, status payload shape, no-session
fallthrough); existing approval/verdict ordering tests refactored
through a shared make_replay_mocks helper in tests/_replay_helpers.py
that both interactive and coord suites import.
2026-04-28 10:27:26 -07:00
Patrick Buckley b1de1584c6 fix(coord): None-safe slice in _evaluate_intent projection
tasks(update) is the only mutation that allows title to be omitted,
so _prepare_tasks stores ``item["title"] = None`` for an update that
only changes status/child_ws_id. _evaluate_intent then projected via
``it.get("title", "")[:100]`` — but dict.get returns the stored None
(the default kicks in only when the key is absent), and the slice
crashed with ``TypeError: 'NoneType' object is not subscriptable``.

The exception fired before any tool in the parallel batch executed,
so the assistant's tool-call message was already on the wire while
no tool-result entries followed.  Reconstruction/sanitisation later
synthesised "Tool execution was cancelled" for every sibling — the
visible symptom that masked the real None-slice failure.

- Switch tasks/notify/task_agent/plan_agent/spawn_workstream/
  spawn_batch/send_to_workstream/close_workstream/close_all_children
  projections to ``(it.get(x) or "")[:N]`` so absent and explicit-None
  both fall back to the empty string.  The other tools weren't
  observed crashing, but the bug shape is identical at every site;
  hardening the projection layer once costs one extra ``or`` per line
  and removes the foot-gun for any future preparer that stores None.
- Regression tests reproduce the original TypeError on
  ``tasks(update)`` without title both standalone and in a parallel
  batch alongside ``tasks(add)``.
2026-04-28 09:50:48 -07:00
Patrick Buckley 39aa493d76 feat(console): per-call model + judge_model on coord composer (#440)
* feat(console): per-call model + judge_model on coord composer

Brings the landing-page coordinator composer toward parity with the
interactive new-ws modal — operators can now pick a model and judge
model per session without round-tripping through the Models admin tab.

- Add Model + Judge Model selects to the home composer's options
  panel, populated from /v1/api/models. Empty / non-string fields
  collapse to None so the factory falls back to ConfigStore defaults
  (coordinator.model_alias, judge.model).
- _coord_create_build_kwargs threads the body fields onto mgr.create.
- Console session factory accepts judge_model and overrides the
  JudgeConfig via dataclasses.replace, mirroring the server-side
  interactive factory's pattern (alias preserved for IntentJudge's
  provider/client resolution).
- Sanitise the 503 factory-misconfig response across make_open_handler,
  make_create_handler, and make_detail_handler: a new
  _safe_factory_misconfig_message helper strips control characters
  and caps at 200 chars before echoing exc text. Operators still get
  the full alias in the warning log; clients see a bounded printable
  string. Defends the user-controlled body["model"] reflection
  surface on the create path.
- _build_mgr_with_factory test helper extracted from _build_mgr so
  tests that need to capture factory kwargs don't reconstruct the
  CoordinatorAdapter + SessionManager scaffolding inline.
- Tests cover: passthrough of model + judge_model, empty / whitespace
  / non-string body fields collapsing to None, and the 503 sanitiser
  truncating + scrubbing a hostile alias payload.

* fixup: address PR #440 Copilot review

- _safe_factory_misconfig_message: hard-cap return at
  _FACTORY_MISCONFIG_MAX_LEN total (was MAX_LEN+1 because the slice
  was MAX_LEN long with the ellipsis appended on top).  Reserve one
  codepoint for the ellipsis so the cap is honoured.  Update the
  regression test to assert the tighter bound.
- Composer judge_model placeholder: "Default (agent model)" was
  misleading when ConfigStore judge.model is set — the actual fallback
  is judge.model when set, IntentJudge's agent-model fallback when
  not.  Use "Default judge model" instead so the label matches both
  configs.
2026-04-28 09:37:54 -07:00
Patrick Buckley 36f7bd5c80 refactor(console): trim landing-page friction
- Drop the duplicate "N nodes · M workstreams" header span — same data is
  already on the page.
- Drop the "+ new" workstream header button + modal; the coordinator
  composer is now the primary entry point on the landing page.
- Always render the NODES list inline; remove the cluster-summary
  compact toggle since the list already self-collapses same-prefix
  nodes into groups.
- Replace the meta node-detail page (#view-node) with direct navigation
  to /node/{node_id}/. Removes drillDownToNode, loadNodeDetail,
  _loadNodeMetadataPanel, the popstate "node" branch, and the
  currentNodeId/currentServerUrl state.
- popstate now falls back to showHome() for unknown state shapes so a
  back-nav from a tab on an older build doesn't no-op.
- test_index_landing_surfaces guards the removed IDs from
  reintroduction.
2026-04-28 08:52:32 -07:00
Patrick Buckley ea204226ad chore: bump version to 1.5.0a5 2026-04-28 00:32:01 -07:00
Patrick Buckley 6f5cb33923 feat(coord): composer parity with interactive — stop/queue/attach (#438)
* feat(coord): composer parity with interactive — stop/queue/attach

Bring the coordinator one-pane UI to feature parity with the
interactive composer: in-composer Stop button replaces Send during a
turn, queue-while-busy with !!! priority + dismiss, paperclip attach
+ drag/drop/paste. The coord backend already supported all three
(lifted send/cancel/attachment handlers, emit_message_queued=True,
supports_attachments=True); this wires the UI through.

Backend:
- Wire make_dequeue_handler(coord_endpoint_config) so DELETE
  /v1/api/workstreams/{ws_id}/send works for coord-kind workstreams.
- Add the matching OpenAPI EndpointSpec.
- Five new test_dequeue_* tests (success, not_found, missing msg_id,
  unknown ws, scope gate) pin the URL/method/scope contract.

Frontend extraction:
- New shared modules composer_attachments.js (createAttachmentController)
  and composer_queue.js (createQueueController) replace ~300 LOC of
  pre-existing duplication between the interactive Pane and the coord
  IIFE. Both panes now share one source of truth for the chip pipeline,
  optimistic queue bubble, and busy-edge promote sweep.

Coordinator pane:
- Composer constructor adds attachments/stopBtn/queueWhileBusy/
  busyPlaceholder/dragDrop options.
- setBusy now drives off SSE state_change (running/thinking/attention →
  busy; idle/error → idle), with composer.setBusy unconditional and the
  edge-only work (timer cleanup + queue.onIdleEdge) gated on the actual
  transition.
- Cancel uses the in-composer Stop with a 2s "Force Stop" affordance +
  10s safety auto-recover; the legacy header-mounted #coord-cancel-btn
  is removed.
- coordCloseSession suspends SSE before close and re-establishes it on
  any failure path so the UI never goes dark on a still-alive session.
- Race handling: bind() releases the queued slot server-side when the
  bubble was already dismissed or promoted; rehydrate re-checks getWsId
  in its .then so a stale-tab response can't clobber the new tab's
  chips.

Interactive pane:
- Pane class adopts the same controllers via this.attachments /
  this.queue. Pane.prototype.uploadAttachment, _renderAttachmentChip,
  _swapPlaceholderChip, _removeAttachmentChip, removeAttachment,
  rehydrateAttachments wrapper, addQueuedMessage, _dequeueMessage, and
  _promoteQueuedMessages are all gone — the controllers own the state.
- setBusy collapses to the same shape as coord: composer.setBusy +
  edge calc + queue.onIdleEdge on idle.

CSS:
- Move .msg-queued / .queued-badge / .queued-dismiss styles from
  ui/static/style.css into shared_static/chat.css so both panes share
  one rendering.
- Add .coord-drop-target overlay rule so the coord pane shows the
  drag-and-drop affordance.

Tests pass: 160 in the impacted suites (coord endpoints + attachments
+ session routes), including 5 new dequeue tests for coord.

* fix(coord): Copilot review + lint follow-ups

Lint:
- ruff: cast(MagicMock, ...) → cast("MagicMock", ...) under
  ``from __future__ import annotations`` (UP037).

Copilot review (PR #438):
- composer_queue _sendDelete now invokes onAfterDequeue on success
  so a bind() race-DELETE (queued bubble dismissed pre-bind or
  promote sweep raced ahead) still rehydrates the caller's chip pile;
  released attachment reservations no longer linger invisibly until
  the next page load.
- Coord's createQueueController gains onAfterDequeue: attachments.
  rehydrate(). The previous omission was a v2 review carry-over from
  before coord supported attachments — now it does, so the same
  contract as interactive applies.
- Both panes' send-response handler now accepts status:queued without
  a queuedEl (SSE-not-yet-connected race on initial load): flips busy
  so subsequent sends queue correctly. The current message keeps its
  optimistic user bubble — accepted UX gap (no in-UI dismiss for
  THIS message) since flipping a rendered user bubble into a queued
  one mid-stream would be jarring.
- Doc updates: chat.css comment + composer_queue.js module docstring
  refer to the renamed onIdleEdge() instead of the removed
  promote()/promoteQueuedMessages.
2026-04-28 00:25:32 -07:00
Patrick Buckley 5ad5f4d12a chore(compose): raise per-node memory caps to fit current footprint
Cluster nodes were OOM-killing under MCP child-process load with the
old 384M/0.5cpu budget chosen for a leaner, pre-MCP turnstone. Bump
each cluster server to 4G/4cpu and postgres to 4G/4cpu. The single-node
server, console, and channel services remain uncapped.
2026-04-27 23:36:09 -07:00
Patrick Buckley dea2729292 refactor(coordinator): rename task_list → tasks, doc/prompt sweep (#437)
Four themes from a coordinator-feature shakedown:

1. Correctness fixes (return shapes / examples / behavior)

   - tools_coordinator.md: drop fake skill names from spawn examples;
     fix wrong kwarg ``node_id=`` → ``target_node=``.
   - wait_for_workstream.json: document ``message`` + ``truncated``
     per-ws fields (always enriched in the client; the JSON shape
     lagged the docstring).
   - cancel_workstream.json: document the conditional ``dropped``
     payload — ``was_running`` always present when ``dropped`` is,
     ``pending_approval`` and ``queued_messages`` conditional sub-shapes.
   - spawn_workstream.json: document full return shape including
     ``routing_strategy ∈ {rendezvous, target_node, resume}`` and
     ``status``.
   - close_all_children.json: clarify ``skipped`` covers BOTH
     hard-deleted children AND already-closed-and-evicted children
     (wire shape doesn't distinguish); drop incorrect "echoed back
     in response" claim — server returns ``{status, closed, failed,
     skipped}``, never echoes ``reason``.
   - console/server.py: comment in ``_fanout_on_children`` clarifying
     that the 400 "No session" branch fires for cancel-cascade
     callers and is unreachable from close_all_children (close
     handler 404s instead).
   - coordinator_client._utc_now_iso(): switch to bare ISO format
     matching the rest of the storage row format used in the codebase.

2. Tightened the 11 longest tool descriptions (~23% cut on the
   coord set). Removed ALL-CAPS emphasis, normalised em-dashes,
   dropped informal phrasing. No new claims.

3. Removed static approval annotations from descriptions.
   Approval is governed at runtime by the unified ``approve_tools``
   body and admin-defined ``tool_policies`` (#436); static
   "Auto-approved" / "Approval required" / per-action approval
   tags become a stale signal. Field names (``pending_approval``)
   and operational verb behaviour ("cancel unblocks pending
   approvals") stay.

4. Renamed ``task_list`` coord tool → ``tasks``. The previous name
   compounded the bare word ``task`` (which collides with chat-template
   channels on local models — same reason ``task_agent`` carries
   the suffix); the plural form sidesteps the collision and reads
   more accurately, since the tool acts on the whole list rather
   than a single task. Sweep covers tool JSON, Python methods (5
   client methods + 2 session methods + 1 helper + 1 constant),
   audit event name (``task_list.update`` → ``tasks.update``), log
   tag (``task_list.corrupt_envelope`` → ``tasks.corrupt_envelope``),
   frontend SSE event matcher, prompts, docs, and tests. CHANGELOG
   entry added.

Plus: dropped the ENV block (Output Environment / Available
rendering / Formatting principles) from coordinator system
prompts. Coordinators orchestrate rather than render rich output
to the user, so the rendering capability matrix is not actionable
for them. Coord prompt drops ~29% (6309 → 4493 chars).

SDK regeneration via ``generate-types.py`` updates both
``openapi-console.json`` (the rename's downstream change) and
``openapi-server.json`` (PR #436 drift — its merge added
``pending_approval_detail`` + ``recent_auto_approvals`` fields to
the Python schemas but didn't regenerate the JSON artifact).

## Behavior changes (operator-visible)

- Audit event name: ``task_list.update`` → ``tasks.update``.
  Audit dashboards / SIEM filters / log greps that pinned the old
  prefix should update.
- SSE ``tool_result`` events now ship ``name="tasks"`` for the
  scratchpad tool. The bundled coord-tree UI is updated atomically;
  external consumers reading SSE events by tool name need to update.
- Existing task envelopes in production storage have ``+00:00``
  timestamps from the old ``_utc_now_iso``. New writes are bare;
  old rows are not backfilled. Within an envelope you may briefly
  see mixed formats until each row is re-touched. No code path
  string-compares timestamps within an envelope, so this is
  cosmetic.

## Validation

- ``ruff check`` + ``ruff format --check`` clean
- ``mypy turnstone/`` clean (175 source files)
- ``pytest -m "not live"`` — 4679 passed, 3 deselected
2026-04-27 22:51:31 -07:00
Patrick Buckley fb44652850 refactor(core): unify approve_tools across both kinds (#436)
* refactor(core): unify approve_tools across kinds + judge visibility + perf

Lift WebUI.approve_tools to SessionUIBase so both interactive and
coordinator workstreams run the same body. The shared body now owns
tool-policy gating, per-tool auto-approve, blanket carve-out for
__budget_override__, activity tagging, heuristic-verdict persistence,
and the approve_request/approval_event blocking pattern. Subclass
hooks layer kind-specific surfaces on top.

This closes the drift the LLM-judge audit flagged on coord — the
judge (heuristic + LLM tier) now sees actual tool args for every
coord tool call instead of empty func_args. spawn_batch projects
the full children list so a malicious mid-batch entry is no longer
hidden.

= Unification core =
- SessionUIBase.approve_tools: lifted body covering policy / per-tool
  auto-approve / blanket / activity tagging / heuristic-verdict
  persistence / approval gate
- _APPROVAL_WAIT_TIMEOUT class constant + _record_judge_metric hook
- WebUI.approve_tools deleted; _record_judge_metric override fires
  per-node MetricsCollector.record_judge_verdict
- ConsoleCoordinatorUI.approve_tools deleted; _record_judge_metric
  + on_intent_verdict overrides fire ConsoleMetrics.record_judge_verdict
- ConsoleMetrics.record_judge_verdict + turnstone_judge_verdicts_total
  in /metrics text output (cluster PromQL rolls coord+interactive up
  uniformly)
- _console_metrics class attribute wired in console lifespan
- Frontend: coord SSE event tools_auto_approved -> tool_info for parity

= Judge args visibility =
- _evaluate_intent populates func_args for all coord tools that hit
  approval (spawn_workstream / spawn_batch / send_to_workstream /
  close_workstream / close_all_children / cancel_workstream /
  delete_workstream / task_list)
- spawn_batch projects every child's skill / initial_message[:200] /
  target_node so the judge sees the full fan-out (was first child only)
- fire_judge_verdict_metric helper collapses 4 sites of identical
  record_judge_verdict shape across WebUI + ConsoleCoordinatorUI

= Hardening =
- __budget_override__ carve-out reads from pre-filter items list, not
  post-filter pending; policy block skips matching the synthetic
  name entirely so a wildcard `*: allow` cannot strip the override
  before the gate sees it
- _persist_intent_verdict default_tier parameter so heuristic + llm
  paths share the storage write helper

= Performance =
- TTL cache on list_tool_policies in turnstone/core/policy.py
  (60s, keyed by org_id, lock-free hits)
- Storage-layer invalidation: create/update/delete_tool_policy on
  both SQLite and PostgreSQL backends call invalidate_policy_cache
  (covers admin-API path + direct test fixtures + any future caller)
- Admin-API handlers also call invalidate_policy_cache as
  defense-in-depth
- storage.create_intent_verdicts_bulk on both backends: one
  multi-row INSERT + one commit instead of N round-trips. approve_tools
  switches to the bulk path so a fan-out turn no longer pays N x commit
  before the approval prompt enqueues
- _persist_intent_verdicts_bulk helper on SessionUIBase

= Test coverage =
- tests/test_coord_ui_approve_tools.py (NEW, 17 cases): inheritance
  regression, tool-policy deny/allow/mixed on coord, heuristic verdict
  persistence (bulk path), activity tagging on auto-approve and pending,
  judge_pending dynamic flag (true + false), event-name parity,
  per-tool auto-approve, __budget_override__ carve-out under blanket
  + wildcard policy, _record_judge_metric wired/unwired, on_intent_verdict
  llm-tier metric
- tests/test_console_metrics.py: 3 cases for the new
  record_judge_verdict counter
- tests/test_judge_storage.py: 3 cases for create_intent_verdicts_bulk
- tests/test_coordinator_tools.py: 3 cases pinning the spawn_batch
  full-children projection (truncation, mid-batch visibility, empty
  defensive)
- tests/conftest.py: autouse _clear_policy_cache fixture so the
  process-level cache doesn't leak between tests with distinct storage
  instances

= Drift fixes (review feedback) =
- Refresh stale "no-op on coord" comments now that coord overrides
  the hook
- WebUI.on_plan_review timeout uses self._APPROVAL_WAIT_TIMEOUT
  instead of literal 3600
- Drop redundant bool() wrapper around any() in judge_pending
- Rephrase broken docstring grammar in _coord_spawn_metrics
- Hoist redundant get_storage import out of approve_tools per-item loop
  (folded into _persist_intent_verdicts_bulk helper)

= Validation =
- pytest -m "not live": 4679 passed, 3 deselected
- ruff check + ruff format: clean
- mypy: no issues in 175 source files

* fix(approval): apply Copilot feedback on PR #436

- Policy-cache invalidation now drops both the org-scoped slot AND the
  default ``""`` slot on ``create_tool_policy`` for both SQLite and
  PostgreSQL backends. ``list_tool_policies("")`` returns rows from
  every org_id, and the production evaluators (SessionUIBase.approve_tools
  / cli.py) read with the default ``org_id=""``, so an org-scoped insert
  that only invalidated its own slot would leave the default cache slot
  stale until the TTL window expired.
- Cap ``reason`` to 200 chars in ``_evaluate_intent`` for ``close_workstream``
  and ``close_all_children`` — both fields are LLM/user-provided and the
  preparer doesn't size-limit them, so an unbounded reason could bloat
  the persisted verdict row's func_args. Matches the cap applied to other
  free-form coord tool fields (initial_message, message, title).
- Refresh ``_PolicyCache`` docstring: it claimed lock-free reads on
  cache hit but ``get()`` always acquires ``self._lock``. Updated to
  reflect that the lock is held briefly to copy the policies reference.

Validation: targeted suite 201/201, ruff + mypy clean.
2026-04-27 21:52:57 -07:00
Patrick Buckley 1fe800f832 refactor(ui): drop legacy ts-composer prefix on shared composer classes
Follow-up to #434.  That PR unified the chat-message primitive on .msg
and noted that the parallel .ts-composer prefix on the shared composer
widget was still in place; this drops it so the widget sits in the
shared/* vocabulary the same way .msg does.

Mechanical 1:1 rename (`ts-composer` -> `composer`) across:

  shared_static/chat.css       — 48 selectors
  shared_static/composer.js    — 19 className strings
  ui/static/style.css          — 11 per-node UI overrides
  ui/static/app.js             — 7 chip queries / className strings

Pre-rename collision check confirmed clean: the only `composer`-substring
matches in the codebase were IDs (#coord-composer-mount, #coord-composer-
panel, #coord-composer-503, #home-coord-composer-mount — IDs are a
different namespace from classes) and the unrelated console
.home-composer-banner / .home-composer-error pair (different prefix).

CSS specificity audit (scripts/css_specificity_audit.py): 26 findings on
origin/main, 26 on this branch — no new cascade flips.

Tests: 189 affected tests pass (test_app_js, test_webui_content,
test_webui_auto_approve_visibility, test_html, test_web_helpers,
test_coordinator_adapter, test_coordinator_client).

Manual visual verification of composer surfaces (textarea, send button,
stop button, attach button + file picker, chip pills + remove buttons,
options panel toggle, paste-image and drag/drop attach paths, stacked
layout used by creation forms) recommended before merge.
2026-04-27 20:35:38 -07:00
Patrick Buckley 94edd741d3 refactor(ui): drop legacy .ts-msg* dual-classing in chat surfaces (#434)
* refactor(ui): drop legacy .ts-msg* dual-classing, chat surfaces share .msg primitive

Third and final follow-up after #431 stripped the data-design="v1"
gate.  This drops the parallel .ts-msg* family that had been kept as a
transitional bridge during the gated rollout.  Per-node UI now renders
pure .msg classes (previously dual-classed as
"ts-msg ts-msg--user msg user"), matching the coordinator chat view
which already used pure .msg*.

chat.css: deleted the ~210-line legacy .ts-msg* rule block (Messages +
floating action toolbar + mobile + reduced-motion sections); renamed
.ts-msg.ts-approval--inline to .msg.ts-approval--inline; restored the
streaming-markdown rationale (white-space: normal intent + partial-fence
behavior + .msg-user-text path) on .msg-body that previously lived on
the deleted .ts-msg-body, with white-space: normal now declared
explicitly so a future "simplification" can't silently break streaming.

ui/static/app.js: dropped the ts-msg* half of every dual-class string
and updated querySelector callsites (.ts-msg--user -> .msg.user,
.ts-msg--assistant -> .msg.assistant).

ui/static/style.css: renamed all .ts-msg--* selectors to .msg.*; removed
two now-dead override rules (.ts-msg.msg:not(.tool) and
.ts-msg-body.msg-body font-family overrides) that existed solely to
unwind the legacy .ts-msg font-mono default that's now gone.

The .msg.ts-approval--inline selector intentionally keeps the .msg
qualifier (rather than bare .ts-approval--inline) so its (0,2,0)
specificity ties with .ts-approval.approved/.denied/.error and the
later-cascade rule wins; without the qualifier those state classes
would suddenly flip the inline-approval card colour based on state.

Composer rename (.ts-composer-* -> .composer-*) deferred to a follow-up
PR; ~80 occurrences across composer.js + chat.css would have obscured
this verification.

Tests: 538 affected tests pass (test_app_js, test_webui_content,
test_webui_auto_approve_visibility, test_web_helpers, test_html,
test_auth, test_console, test_api_versioning, test_coordinator_*).
Visual verification (message cards, hover toolbar, approval/denial/error
cards in light + dark themes) recommended before merge.

* docs(ui): clarify .msg.reasoning emission comment per Copilot review

The previous wording — ".reasoning as a bare role class is no longer
emitted" — implied .reasoning is never emitted, but the new className
is "msg reasoning" so .reasoning IS emitted, just always alongside .msg.
Reword to make the actual invariant (never on its own) explicit.
2026-04-27 20:19:59 -07:00
Patrick Buckley 4b5edce8c5 fix(css): two cascade-flip bugs found by specificity audit (#433)
* fix(css): two cascade-flip bugs found by specificity audit

PR #431 stripped [data-design="v1"] from ~400 rules, dropping each by a
specificity tier; two cascade flips (#header outranking .appbar, #header h1
outranking .appbar-title) were caught visually during that PR's review and
fixed by renaming id="header" → id="ui-header" on the per-node UI page.
This is the audit follow-up; it found two more:

- textarea.skill-content-area (was .skill-content-area) — bumped to (0,1,1)
  so the rule ties with `.admin-modal textarea` (0,1,1) and wins on source
  order. Without the bump, min-height: 220px was clobbered to 40px by the
  modal default and the spec-content textarea rendered short. The three
  !important markers (font-family/size/line-height) are now redundant
  against the modal's font: inherit shorthand and are dropped.

- h3.skill-spec-heading — removed `font-size: inherit;`. The author wrote
  it to "reset UA defaults" but it locked font-size to the parent's
  (~14-16px) at (0,1,1), silently overriding `.skill-spec-heading`'s 10px
  at (0,1,0). The bare class already beats UA `h3` on specificity (class >
  tag), so no font-size reset was needed; the `margin-block: 0` line stays
  because the bare class's `margin: 14px 0 6px` shorthand may not reset
  the UA's logical margin-block-start/end on every engine.

Adds scripts/css_specificity_audit.py — the audit tool. It parses every
CSS file referenced from the project's three HTML entry points, computes
selector specificity (incl. :not/:is/:has math, attribute selectors, and
!important), and flags every place an unscoped legacy rule could outrank
a bare-class designed primitive. Honours per-page stylesheet manifests,
state-pseudo subset gating (a `:hover` rule overriding a resting-state
base rule is intentional, not a flip), and shorthand→longhand expansion
for font/padding/margin/border/background. Triage of remaining findings
(26 id-tier in default mode, 74 total at --all-tiers) confirmed all are
intentional designer overrides — id-scoped buttons, BEM modifier classes,
contextual ancestor selectors, last-child margin reset, [hidden] toggle.

* fix(css-audit): correct two cascade-resolution bugs flagged by Copilot

1. _parse_declarations dict insertion order didn't update on overwrite, so
   a sequence like `font-size: 13px; font: inherit; font-size: 12px;` would
   iterate as (font-size=12px, font=inherit) and the shorthand expansion
   then clobbered font-size back to `inherit` — wrong.  Delete-then-insert
   on overwrite so the last occurrence lands at the dict's tail and the
   shorthand expansion sees the real source order.

2. The cascade-winner tie-break used `rule.line_no` only, ignoring the
   stylesheet load order.  A rule at line 1000 of `base.css` looked
   "later" than a rule at line 50 of `style.css`, even though the page
   loads `base.css` BEFORE `style.css`.  Sort by `(file_index, line_no)`
   keyed off the element's per-page stylesheet manifest instead.
2026-04-27 20:05:25 -07:00
Patrick Buckley 83a97ba485 fix(ui): preserve approval pill when tool errors (#432)
* fix(ui): preserve approval pill when tool errors

When an approved (or auto-approved) tool subsequently failed during
execution, both `replayHistory` and `appendToolOutput` located the
existing `.ts-approval-badge` and overwrote its className + textContent
with the `--error` variant — losing the record that the user had
approved the call.

Append a separate `--error` pill as a sibling of the existing approval
pill instead. The `.ts-approval` parent is `flex-direction: column` with
a 6px gap, so the two pills stack vertically and read as a small
status timeline ("you approved this, then it errored"). Idempotency
guard via `querySelector(".ts-approval-badge--error")` so duplicate
fires don't stack badges.

CSS classes are unchanged (the `--error` modifier already exists in
both per-node and shared chat stylesheets).

Adds a static-string guard in `tests/test_app_js.py` that pins both
call sites and forbids the mutate-in-place anti-pattern via a regex
that pairs a queried `.ts-approval-badge` handle with an `--error`
className overwrite.

Deferred from #431.

* refactor(ui): extract appendToolErrorBadge helper, broaden test guard

Address Copilot feedback on #432:

- Extract the duplicated 5-line error-pill construction into a single
  module-level `appendToolErrorBadge(blockEl)` helper next to the
  other approval-related helpers (`buildToolDiv`, `renderVerdictBadge`,
  `toggleVerdictDetail`). Reduces drift risk on ARIA / class / text
  string between the two call sites.

- Loosen the affirmative test check from a literal substring keyed on
  the local variable name to a regex matching any
  `querySelector(".ts-approval-badge--error")` lookup, in either
  quote style, in either guard idiom (`if (!q) {...}` at a call site
  or `if (q) return;` inside the helper). A future refactor that
  preserves behaviour shouldn't trip CI on cosmetics.

- Broaden the anti-pattern regex to accept single quotes and to
  catch the `classList.add("ts-approval-badge--error")` form on a
  queried badge handle, not only `className = "..."`.
2026-04-27 19:37:26 -07:00
Patrick Buckley cc20c7008d refactor(css): unify design system, eliminate data-design="v1" gating (#431)
* refactor(css): unify design system, eliminate data-design="v1" gating

Strip the [data-design="v1"] attribute that was wrapping every DS rule
since #389 and never came back out. Result: every styled element on
coord/ui pages had two CSS rules (default + v1-gated), reviewers
couldn't tell which one rendered, and the bundle shipped duplicates.

Changes:
* Strip [data-design="v1"] prefix from ~400 gated rules.  Remove the
  attribute from coordinator/index.html, ui/index.html, preview.html.
* Merge shared_static/design/* into pre-v1 sheets:
    tokens + typography  → base.css (:root, dark default)
    appbar + panel + buttons + pills + field  → ui-base.css
    message primitives   → chat.css
    sidebar + approval-dock → console/static/coordinator/coordinator.css
  (new file linked from coord only — admin no longer ships ~9KB of
  coordinator-only chrome on first load).
* Delete preview.html + 5 preview-only orphan stylesheets (topbar /
  stats / feed / fleet-grid / live-feed).  Drop the empty
  shared_static/design/ directory.
* Drop unused primitives the merge dragged in: .pill / .k-badge /
  .chip / .field / .t-* utilities / .side-item / .shell.  Drop unused
  tokens (--accent-c / --accent-l / --row-h / --density / --gap /
  --font-display alias).  Find-replace var(--font-display) →
  var(--font-ui) across 6 files (103 sites).
* Standardize on the DS font stack: Inter body, JetBrains Mono code.
  Admin's body shifts from IBM Plex Mono → Inter via the alias rename.
* Re-tune legacy --bg/--bg-surface/--bg-highlight/--bg-elevated from
  steel-blue to neutral charcoal so admin and v1 pages share one
  palette.  Drop the cyan radial-gradient overlay on body that added a
  blue tint to the formerly-blue bg.
* Polish:
  - .msg.tool / .ts-msg--tool / .ts-approval / inline-approval all use
    --cyan instead of amber, removing the user/tool colour collision.
  - .msg-action-btn reverts to icon-button styling (transparent,
    28x24) after the merge gave it text-button chrome that dwarfed the
    13-14px icon glyphs inside.
  - Light-mode composer contrast: flip .ts-composer surface roles
    (wrapper recessed, textarea elevated) so the textarea reads
    against its container; bump .dashboard-composer textarea
    border-bottom + options panel surface so they're visible on white.
  - .pane-messages padding 20px -> 16px 12px and gap 14px -> 0 (the
    flexbox gap was stacking with .ts-msg margin-bottom for ~18px
    inter-card spacing); .ts-msg/.msg margin-bottom 8px -> 4px.
  - Restore WCAG 2.5.5 36x36 touch target on .msg-action-btn.
  - Rename ui's <div id="header"> to id="ui-header" so the legacy
    #header chrome no longer outranks the .appbar primitive on the
    per-node page (3 getElementById calls in app.js updated).
  - Drop chat.css link from coord (coord renders pure DS classes; the
    composer.js consumer of chat.css is on admin + ui only).

Verified: ruff + mypy clean (175 files); 4653 non-live tests pass;
zero data-design / --font-display / shared_static/design hits remain.
Net source change: -1885 lines (924 added, 2809 deleted across 28 files).

* build: include coordinator.css in wheel; drop dead design/ glob

The previous commit added turnstone/console/static/coordinator/coordinator.css
(coord-only chrome moved out of console/static/style.css) but didn't
update the [tool.hatch.build.targets.wheel] include list, so CI's
wheel-completeness check failed.

Also drop the now-stale 'turnstone/shared_static/design/**/*' glob —
that directory was deleted in the same v1-elimination commit.

Verified locally: replicating the CI step's source-vs-wheel diff
returns MISSING: none.

* fix(css): address Copilot review feedback on PR #431

* coordinator/index.html — comment now correctly points to the moved
  .sidebar rules at console/static/coordinator/coordinator.css (was
  console/static/style.css before the perf-1 split-out).
* ui/static/index.html — restore <h1 class="appbar-title">; the cascade
  conflict that motivated the h1→div change is gone now that the
  wrapper id was renamed away from #header (the legacy #header h1 rule
  no longer matches).  Page semantics + accessibility regain the
  top-level heading.
* governance.js — drop the inline font-family:var(--font-ui) on the
  config-key <code> elements; let them inherit the global mono default
  from base.css.  The inline style was an artifact of the
  --font-display → --font-ui find-replace; the original Outfit was
  already odd on a <code> tag.
* ui-base.css — typography-helpers comment said "Body text still
  inherits var(--font-mono) at 13px" but base.css now sets var(--font-ui)
  at 14px.  Reword to match current defaults.
2026-04-27 19:04:15 -07:00
Patrick Buckley 9b5096fe3c fix(approve): visibility for child tool calls bypassing operator gate (#430)
* fix(approve): visibility for child tool calls bypassing operator gate

When a coord LLM spawns a child with `skill="X"`, the skill template's
`allowed_tools` JSON list silently populates the child UI's
`auto_approve_tools` set. Tool calls whose names are in that set
short-circuit the approval gate without prompting the operator —
matching the user-reported bug "tool calls of children occasionally
getting approved instead of waiting for approve/deny".

The auto-approve paths themselves are unchanged (Option C — visibility
only). Surfaces:

- Per-item annotations: each pending tool gets `auto_approved=True` +
  `auto_approve_reason` ("skill" / "always" / "policy" / "blanket" /
  "auto_approve_tools") at the four gate-bypass paths.
- Per-ws ring buffer (cap 10) of recent bypasses, exposed via
  `/dashboard` and the cluster live-bulk projection so the coord-
  tree row can render an "auto-approved by ..." pill.
- `tool.auto_approved` audit row per `approve_tools` call —
  forensic durability beyond the in-memory ring buffer.
- Per-ws WebUI page: inline "auto: <reason>" badge next to each
  tool name, so an operator who clicks through from the coord tree
  to the child's page sees the same bypass signal.

Persistence across UI rebuilds:
- The ring buffer is in-memory only; a saved-workstream rehydrate /
  coord→node click-through / process restart all build a fresh UI.
  `replay_recent_auto_approvals_from_audit` runs at the end of
  `SessionUIBase.__init__` and re-seeds the buffer from recent
  `tool.auto_approved` audit rows scoped to this ws_id.
- Adds `resource_id` filter to `list_audit_events` (protocol +
  SQLite + Postgres) so the replay is a single indexed query.

Source provenance:
- `_auto_approve_tools_source: dict[str, str]` per UI tracks which
  writer added each tool name to `auto_approve_tools` ("skill" at
  skill-template setup time, "always" on Approve+Always click).
  Lets the dashboard pill distinguish a skill-driven bypass from
  an explicit operator-Always click — those are very different
  signals that previously rendered the same.

Magic-string drift mitigation:
- `AutoApproveReason` constants in `core/session_ui_base.py` lift
  the five reason strings into a single source of truth.
- `KNOWN_AUTO_APPROVE_REASONS` JS constant + validator render
  unknown reasons as "unknown" with a console.warn instead of
  rendering raw (a typo would otherwise silently desync wire ↔
  pill).

Recording-leak fixes (q-2 from review):
- Policy `allow` partial-resolve now records the policy-tagged
  items at two previously-leaking branches: the early-return-on-
  deny path and the still_pending-non-empty fall-through to the
  prompt path.

Other review fixes:
- Heuristic verdict surfaces consistently as `heuristic_verdict`
  in both `_serialize_approval_items` and the dashboard
  serializer (was inconsistent: one emitted `verdict`, the other
  `heuristic_verdict`). app.js updated to read either key for
  mid-deploy compatibility.
- `_tag_auto_approved` helper on SessionUIBase replaces the
  verbatim tag loops previously copy-pasted across WebUI and
  ConsoleCoordinatorUI.

* fix(approve): apply Copilot review feedback on PR #430

- coordinator_ui: use ``approval_label or func_name`` for the
  ``auto_approve_tools`` subset check, matching WebUI.  Pre-fix
  an "Approve + Always" entry whose approval_label differs from
  func_name (skill__name, mcp_resource__uri) wouldn't match on
  the coord page and the operator would be re-prompted.
- _parse_audit_timestamp: treat naive ISO strings as UTC.  Audit
  rows are written via ``datetime.now(UTC).strftime(...)`` with
  no timezone marker; ``datetime.fromisoformat`` returns a naive
  datetime, and ``.timestamp()`` on a naive datetime interprets
  it in the server's local timezone — wrong on any non-UTC
  server.  Stamp UTC explicitly before converting.
- server.py: drop the dead ``pending = []`` after the blanket
  tag — the function returns inside the same block without
  reading ``pending`` again.
- _protocol.py: fix docstring reference from
  ``_replay_recent_auto_approvals`` to
  ``replay_recent_auto_approvals_from_audit`` (the actual
  method name).
2026-04-27 15:51:01 -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 e33519275e docs(coord): fix wait_for_workstream message-field claim re deleted state
Copilot caught a doc/code mismatch from the q-2 cleanup: the
docstring still claimed `closed` / `deleted` / `denied` all return a
sentinel, but the `deleted` branch was dropped (hard deletes cascade
rows out of storage so the state is unreachable). Update the
docstring to align with `_wait_message_for`'s actual behaviour —
`deleted` falls into the same null-message shape as a still-running
entry.
2026-04-27 13:56:16 -07:00
Patrick Buckley 91b07aaf4b feat(coord): bundle child last-message inline in wait_for_workstream
Each per-ws snapshot now carries `message` + `truncated` so the
coordinator LLM doesn't need a follow-up `inspect_workstream`
round-trip per child to read what came back. idle/error states
return the last assistant turn (capped at 6 KiB UTF-8 bytes,
truncated from the end); closed/denied return a sentinel; running
children carry null. Storage reads for idle/error parallelize
across an 8-worker thread pool so a 32-child fan-out lands in 4
batches instead of 32 sequential round-trips.
2026-04-27 13:56:16 -07:00
Patrick Buckley 15d5ddde12 fix(ui): bootstrap pane in switchTab when none exists (#427)
Creating or opening a workstream from the dashboard left the chat
UI blank until the operator refreshed: switchTab early-returned at
``if (!pane) return;`` because getFocusedPane was null on a fresh-
loaded page that had no workstreams. The freshly-created ws was
added to the workstreams dict and the dashboard was hidden, but no
pane was bootstrapped, no SSE connected, and the chat area sat
empty until refresh — at which point initWorkstreams saw the
populated list and bootstrapped the pane via the existing
"if (!Object.keys(panes).length)" branch.

switchTab now mirrors that bootstrap when no focused pane exists:
createPane + splitRoot leaf + setFocusedPane + renderLayout. The
rest of switchTab (disconnectSSE / reset / connectSSE) runs as
before — no-ops on the just-constructed pane up to the connectSSE
call which is exactly what we want.

Subsequent creations on the same node already worked because the
first create populated panes and switchTab found a focused one.

Static smoke test in tests/test_app_js.py guards against the
early-return regressing.
2026-04-27 13:03:56 -07:00
Patrick Buckley 438e6f41ba feat(renderer): progressive mermaid rendering during streaming (#426)
* feat(renderer): progressive mermaid rendering during streaming

Mermaid diagrams used to materialize all-at-once at stream_end via
streamingRenderFinalize, which felt laggy on long responses with
multiple diagrams. Now closed mermaid fences render progressively
as each fence completes during streaming.

The blocker was streamingRender's wholesale `el.innerHTML = html`
on every rAF tick, which destroys any rendered SVG nodes — without
caching, calling postRenderMermaid per tick would re-trigger an
async mermaid.render every time, thrashing the renderer.

Added a source-keyed SVG cache (_mermaidSvgCache, FIFO-bounded at
64 entries):

  - Cache hit on identical source: synchronous innerHTML swap, no
    loading flash, no async work. Mermaid is deterministic for a
    given init, so identical source ⇒ identical SVG, safe to reuse.
  - Cache miss: queue async render, populate cache on success.
  - Errored sources cached separately (_mermaidErrorCache) so a
    syntactically-broken diagram doesn't re-thrash mermaid on every
    tick. The user can fix the diagram and the new source string
    misses the cache, triggering a fresh render.

_streamingRenderApply now calls postRenderMermaid after the
innerHTML replace. Per-stream cost: each unique mermaid source
pays mermaid.render once, then synchronous cache hits for every
subsequent rAF tick. hljs syntax highlighting stays deferred to
streamingRenderFinalize (it's a separate pass and benefits less
from progressive rendering — code blocks tend to be short and
already legible without color).

Tests: built a richer Node-driven harness with a fake DOM that
tracks attributes / classList / parent chain / replaceWith, plus
a stubbed mermaid.render with a call counter. 5 new tests cover:
cache-hit skips render, distinct sources render independently,
errors cache to avoid thrash, FIFO eviction at cap, and a static
guard that _streamingRenderApply actually calls postRenderMermaid.

* fix(renderer): apply Copilot feedback on PR #426

Six review items, all real:

1. _cacheMermaidEntry evicted on overwrite — overwriting an
   existing source unnecessarily dropped the oldest entry.
   Now: only evict when inserting a new key.

2. _initMermaid didn't clear caches — a theme change via
   reRenderAllMermaid (which calls _initMermaid) would serve
   stale SVG keyed by source-only, since rendered output
   depends on themeVariables. Now clears both caches on
   (re-)init.

3. bindFunctions never re-applied on cache hits — mermaid's
   bindFunctions attaches link/click handlers to each rendered
   SVG instance. Pre-fix, only the first render got bindings;
   subsequent cache hits via raw innerHTML left the SVG inert.
   Cache value is now {svg, bindFunctions}; cache hits go
   through _applyMermaidSvg which re-applies bindings on each
   new container instance.

4. Truthiness checks on cache lookups — empty-string SVG / error
   would have masqueraded as a miss. Switched to cache.has()
   (and then .get) so intent is explicit.

5. Concurrent mermaid.render — postRenderMermaid now fires on
   every streaming rAF tick, so multiple ticks could overlap
   while earlier render Promises pend. mermaid.render uses
   module-level state internally — concurrent calls clobber it.
   Two layers of serialization fix this:
   - _mermaidPending: per-source. While a render is in flight
     for source X, additional containers asking for X are queued
     and the single render result fans out to all pending
     containers when it lands.
   - _mermaidRenderChain: across-source. Promises chain so
     mermaid.render runs at most one at a time globally.
   - Detached containers (no longer in the DOM by the time the
     render completes) are skipped via isConnected guard —
     wholesale innerHTML replace during streaming detaches them
     and a later tick is already taking care of the live one.

6. Test brittleness — _streamingRenderApply guard used
   body.index("\\n}\\n", start) which would stop at the first
   inner-block closing brace inside the function. Switched to a
   bounded-window string search (Copilot's suggestion).

Three new tests added: overwrite doesn't evict; _initMermaid
clears caches; cache hit re-applies bindFunctions. Existing tests
updated for the new {svg, bindFunctions} cache shape and the
async serialization (drain via setTimeout hops instead of bare
microtask resolves).

Test harness fix: fake DOM elements now have an isConnected
getter derived from the parent chain, so the new guard
exercises correctly under test.
2026-04-27 12:47:21 -07:00
Patrick Buckley 33d16d19ce fix(renderer): handle LaTeX-style \(...\) and \[...\] math delimiters (#425)
* fix(renderer): handle LaTeX-style \(...\) and \[...\] math delimiters

The browser renderer at turnstone/shared_static/renderer.js only
recognized TeX-style $...$ / $$...$$ delimiters. Most modern LLMs
(GPT-5 / o-series, Claude with reasoning effort) emit LaTeX-style
\(...\) for inline math and \[...\] for display by default — those
slipped through as raw text in the coord + interactive WebUIs,
making KaTeX appear "broken when nested inside a markdown block"
(actually broken everywhere, the surrounding markdown just made
the failure noticeable).

Added a second pass for each delimiter style alongside the
existing $...$ / $$...$$ patterns. Both styles now feed the same
mathBlocks / inlineMaths placeholder pipeline so all the existing
nested-block handling (lists, blockquotes, tables, bold, headings,
details, post-render KaTeX markup) Just Works.

Edge cases verified by the new test_renderer_js.py harness:
- \(...\) inside inline code stays literal
- \(...\) inside fenced code blocks stays literal
- Solo \[ with no closing \] doesn't trigger spurious math
- Markdown links [text](url) untouched (regex uses \[ \], not [ ])
- Mixed TeX + LaTeX delimiters in one message both render

The harness drives renderer.js through Node via vm.runInThisContext
with stubbed document/katex globals — first JS-side regression
guard for the renderer; previously it had no test coverage at all.

* fix(renderer): apply Copilot feedback on PR #425

Three review items from Copilot:

1. Display-math sentinel could leak through inline-code spans.
   The original ordering ran $$...$$ / \[...\] extraction BEFORE
   inline code, so a backtick span around math (e.g. `$$x$$` or
   `\[x\]`) had its delimiters consumed by the math regex and
   replaced with \x00MB…\x00. Inline code then captured the
   sentinel; restore order put MB after IC, leaving the null-byte
   placeholder visible inside the rendered <code>. Reorder: inline
   code first, then display math, then inline math. Code spans
   now seal their content before any math regex sees it. The
   reverse edge case (math containing backticks, e.g. \verb|`x`|)
   is much rarer and KaTeX rejects \verb anyway.

2. Inline LaTeX-style \(...\) regex used [\s\S]+? which allowed
   newlines, so an unterminated \( on one line would eat the
   next paragraph until it found a closing \). Aligned with the
   existing $...$ behavior by switching to [^\n]+? — display
   math (\[...\] / $$...$$) stays multi-line by design.

3. tests/test_renderer_js.py was guarded with a node-availability
   skip, but CI's test + test-postgres jobs didn't explicitly
   install Node, so the suite would have silently no-op'd if the
   runner image dropped Node. Added actions/setup-node@v5 to
   both jobs.

Four new regression tests cover the leak (both delimiter styles
inside backticks must stay literal) and the cross-paragraph span
(both \(...\) and $...$ must not eat newlines).
2026-04-27 12:19:19 -07:00
Patrick Buckley 1f271789b3 fix(approve): global judge poll + Copilot round-2 feedback
Bug: LLM judge verdicts stayed stuck on heuristic-only render.
Root cause: per-row poller called scheduleLiveFetch which
short-circuits on non-visible rows — invalidate cleared the
cache, no fetch fired, the row kept rendering its last-cached
heuristic indefinitely. The 12s attempt cap also gave up before
slow LLM judges (>15s with reasoning effort) could land.

Replaced with a single global poller _maybeStartJudgePoll /
_judgePollTick:
  - Walks the full childrenState (not just visible rows)
  - Bypasses scheduleLiveFetch's visibility + TTL gates by
    adding to pendingLiveIds directly + flushing
  - One bulk request covers every pending row per tick
  - Self-terminates when every verdict lands or 90s elapses
    (operator can hit Refresh to retry on a failed judge)
  - 90s cap is wall-clock, not attempt count, so an LLM that
    takes 60s no longer prematurely gives up

Copilot round-2 feedback:

- _proxy_sse with use_service_auth=True silently fell back to
  empty headers when proxy_token_mgr was None, producing a
  retry-storm 401/403 loop. Fail fast with a 503 + clear log
  so the misconfig surfaces immediately.

- Mobile <700px CSS comment claimed buttons "stretch to full
  row width" but the rule keeps flex-direction: row with
  flex: 1 on each, giving 50/50 side-by-side. Updated the
  comment to match the deliberate side-by-side layout
  (stacking would push the action row below preview/disclosure
  on tall envelopes; 50/50 keeps both verbs reachable).
2026-04-27 11:41:14 -07:00
Patrick Buckley 3b92c96b31 fix(coord): align coord client routes with post-#422 path-keyed mounts
#422's legacy URL adapter removal deleted the body-keyed
/v1/api/route/{verb} endpoints (with ws_id in JSON body) but
turnstone/console/coordinator_client.py still pointed at them.
The coord LLM's close_workstream / close_all_children tools
404'd; send / approve / cancel were equally broken though
exercised less often.

_ROUTE_PATHS now uses {ws_id}-templated path-keyed forms:
  send → /v1/api/route/workstreams/{ws_id}/send
  approve → /v1/api/route/workstreams/{ws_id}/approve
  cancel → /v1/api/route/workstreams/{ws_id}/cancel
  close → /v1/api/route/workstreams/{ws_id}/close

_post() interpolates {ws_id} at call time when the template has
the slot; body-keyed paths (delete, close_all_children) still
work via the same code path. Each affected caller (send, approve,
cancel, close_workstream, close_all_children) was updated to pass
ws_id as the kwarg and drop ws_id from the body.

Added test_route_paths_match_actual_console_mounts: walks the
real Starlette app's routes and asserts every _ROUTE_PATHS entry
corresponds to an actually-mounted route. Catches the next URL
unification drift before runtime. Updated the existing literal
assertions + path-checking tests for the new shape.

Pre-existing bug surfaced while testing inline-child-approvals.
2026-04-27 11:41:14 -07:00
Patrick Buckley 93875ebca5 fix(console): proxy events/global with service auth (not user JWT)
The interactive WebUI's app.js opens an EventSource against
/v1/api/events/global on load (cluster-wide tab indicators,
ws_state for the dashboard). When loaded via the console proxy
at /node/{node_id}/, the JS shim rewrites that to
/node/node-X/v1/api/events/global and the proxy forwards using
the user's re-minted JWT.

Upstream global_events_sse requires `service` scope by design
— the stream carries cross-tenant cluster inventory, intended
for the cluster collector, not browsers. End-user JWTs don't
carry service scope, so every proxied call returned 403, the
browser auto-retried with exponential backoff, and the console
log filled with proxy.sse.non_200 warnings.

_proxy_sse gains a use_service_auth flag. proxy_api flips it on
for events/global only, swapping the user JWT for the console's
proxy_token_mgr bearer token. Per-ws events stay on user auth
(tenant filtering on the upstream still requires user identity).

The upstream-side privacy posture is unchanged — the data on
events/global is the same cluster-wide inventory the console's
own /v1/api/cluster/events endpoint already serves to any
read-scoped caller under the trusted-team posture. The console's
AuthMiddleware on /node/{node_id}/v1/api/ remains the gate that
decides who can use the proxy at all.
2026-04-27 11:41:14 -07:00
Patrick Buckley b0f78ae4c0 fix(console): route per-workstream events to SSE proxy
The console's node-API passthrough at /node/{node_id}/v1/api/{path}
detected SSE only on the bare events / events/global paths. After
#422 removed the legacy /v1/api/events?ws_id= shape and moved
per-workstream SSE under /v1/api/workstreams/{ws_id}/events, the
proxy never got updated to match the new path — per-ws events
fell through to the regular GET branch, the upstream returned a
text/event-stream payload that the regular GET response couldn't
hold open, and Firefox surfaced the failure as "can't establish a
connection to the server".

Extend the SSE detection to also match
``workstreams/{ws_id}/events``. Pre-existing bug surfaced while
testing inline-child-approvals (operator clicks through from the
coord tree to the per-child interactive WebUI) but affects every
caller hitting a node's per-ws events stream via the console
proxy.

Two new tests in TestConsoleProxy: per-ws events route to
_proxy_sse with the correct upstream path; existing
events/global routing still works.
2026-04-27 11:41:14 -07:00
Patrick Buckley ebf562de93 fix(approve): suppress 409 storm from rapid approve/deny clicks
Previously the 409 stale-call_id branch in submitChildApproval
re-enabled both buttons synchronously before kicking off the
urgent live-bulk refresh. That opened a window where rapid clicks
on an already-resolved approval (or a row whose call_id had
rolled) each re-armed the click handler, fired another POST, and
collected another 409. Operators rage-clicking saw a network 409
storm and a stack of warning toasts.

Keep the buttons disabled in the 409 path. The row is about to be
re-rendered wholesale via the urgent refresh — the disabled DOM
gets dropped along with it. If the row's approval truly resolved,
the new render has no buttons. If a new round started, the new
render has fresh enabled buttons. Either way the operator-facing
signal IS the row updating, not the toast.

Drop the toast.warn (noisy on every rapid-click race) in favour
of a single console.warn for diagnostics.

If the urgent refresh fails entirely, the buttons stay disabled
on that row — but the operator can hit the Refresh button on the
children panel to force a full reload. Acceptable degraded state
vs the previous 409 loop.
2026-04-27 11:41:14 -07:00
Patrick Buckley 4d08a19bd5 fix(approve): replay cached LLM verdicts on coord SSE reconnect
The coord's _coord_events_replay re-yielded _pending_approval on
connect but not the cached _llm_verdicts entries. A tab refreshing
mid-approval saw the approve_request prompt without the judge chip
because intent_verdict is a one-shot SSE event with no
late-subscriber push — the chip would only ever land if the operator
re-invoked the tool call.

Mirrored the interactive path at turnstone/server.py:875-878:
after re-injecting the pending_approval prompt, walk
ui._llm_verdicts under _ws_lock and yield each cached verdict as
an intent_verdict event. Pre-existing bug surfaced during the
inline-child-approvals work but the coord-self dock UX was always
affected on reconnect — not introduced by this PR.

Two new tests: cached verdicts replay after pending_approval; stale
verdicts from a prior round don't replay when no approval is pending.
2026-04-27 11:41:14 -07:00
Patrick Buckley 68e1332c59 fix(approve): route child approvals through proxy + poll for late judge verdict
Two bugs reported from local repro on PR #424:

1. Approve/Deny buttons return HTTP 404 on every click. The new
   approveWorkstream helper hit /v1/api/workstreams/{ws_id}/approve
   regardless of target — that path is only mounted for coord
   workstreams (which live on the console process). Child
   workstreams live on cluster nodes and need to round-trip
   through the routing proxy at
   /v1/api/route/workstreams/{ws_id}/approve, which resolves the
   ws_id to its owning node and forwards the body verbatim.
   approveWorkstream now picks the path based on whether targetWsId
   matches the coord's own wsId.

2. LLM judge verdict never populates — rows freeze on the
   heuristic-tier pill ("⚙ heuristic") even after the judge would
   have completed. The judge runs async on the child node via a
   daemon thread and updates _llm_verdicts there, but no signal
   propagates back to the coord — cluster_state events don't fire
   on verdict-only changes, and the live-bulk TTL is 5s with no
   periodic poll.

   Added _maybePollForJudgeVerdict: when renderChildRow encounters
   a pending_approval_detail with judge_pending=true and items
   missing judge_verdict, schedule a recursive 2s urgent
   live-bulk re-fetch. Self-terminates when the verdict lands,
   the row closes, the approval clears, or attempts hit the cap
   (≈12s for a failed/timed-out judge so we don't poll forever).
   Single timer per ws_id; re-renders are no-ops while a timer is
   in flight.

Smoke-test assertions added for both fixes so a regression on
either path surfaces at test-time.
2026-04-27 11:41:14 -07:00
Patrick Buckley a23ef7306c fix(approve): apply Copilot feedback + remove plan doc
Copilot review on PR #424 flagged three items:

1. Schema drift on /v1/api/dashboard — DashboardWorkstream didn't
   declare the new pending_approval_detail field, so generated
   OpenAPI / typed clients were out of sync. Added
   PendingApprovalItem + PendingApprovalDetail Pydantic models
   and referenced PendingApprovalDetail from DashboardWorkstream.

2. deepcopy under _ws_lock in serialize_pending_approval_detail
   could extend lock hold under contention with on_intent_verdict
   (daemon judge thread) and per-token activity writes that also
   take _ws_lock. _llm_verdicts entries are only assigned/cleared,
   never mutated in place, so a snapped reference is stable after
   the lock drops. Snapshot refs under lock; deepcopy after release.

3. Plan doc removed from the branch — design docs are local-only
   working artifacts, same posture as PROGRESS.md.
2026-04-27 11:41:14 -07:00
Patrick Buckley 7e33fc68bb fix(approve): apply /review feedback on inline child approvals
Critical:
- coordinator.js RISK_SEVERITY accepted 'crit' only; production
  emits 'critical' (per turnstone/core/judge.py:1556 + heuristic
  seeds). A risk_level=='critical' verdict ranked as 0 and
  rendered with .risk.low (green) styling, never triggering
  the crit-risk auto-expand. Now accepts both aliases. Unknown
  risk_level falls back to rank 2 ('high') so future schema
  drift fails *safe* (over-alert) instead of silently
  downgrading. Pill ternary handles both 'crit' and 'critical'
  alias to the existing .risk.crit class.

Major:
- Urgent live-badge flush now coalesces N urgent calls in the
  same JS tick into one bulk request via queueMicrotask, instead
  of firing N single-id fetches. The motivating 10-children-
  pending-bash scenario in the design doc now lands on one bulk
  /v1/api/cluster/ws/live request.
- Test coverage gap: added test_session_ui_base.py cases for
  POLICY-BLOCKED (item.error + needs_approval=False) and
  judge-unavailable (no verdict + no judge_pending) matrix rows.
  Added literal-string assertions to the smoke list in
  test_coordinator_page.py so a refactor dropping either branch
  surfaces at test-time.

Minor batch (4 coord.js + 1 CSS + 1 fake-divergence):
- 409 stale-call_id path re-enables both buttons before return
  (urgent fetch is best-effort; could also fail).
- judgePending pill no longer conflicts with a present heuristic
  verdict — guard changed from !judge to !verdict.
- Empty <div class="approval-reasoning"> no longer appended when
  reasoning is absent but evidence is present (evidence still
  renders inside the disclosure).
- Dead .ch-row .approval-pill.rec-* CSS rules removed (JS never
  combines those classes). Recommendation chip in the disclosure
  footer now has its own scoped rules so the chip is actually
  styled.
- _FakeUI.serialize_pending_approval_detail call_id selection
  aligned to the real impl's "first non-empty" semantics.
- liveBadgeCache reconnect cleanup now preserves permanent
  (403/404) entries — denied users no longer pay one wasted
  bulk fetch per denied id per reconnect.

All 4465 non-live tests pass. Ruff + mypy clean. node --check OK.
2026-04-27 11:41:14 -07:00
Patrick Buckley a369d5f0d0 feat(approve): clear live cache on SSE reconnect — chunk 4 reconnect parity
Closes the stale-button window where a sub-5s SSE gap would leave
liveBadgeCache holding pending_approval_detail for a child whose
approval was actually resolved during the gap. Without this clear,
zombie approve/deny buttons render until either the next child_ws_state
event or the natural TTL expiry (whichever comes first).

The clear sits beside the existing activeWaits.clear() in the
reconnect handler — same posture (drop client-only state that the
server's SSE replay doesn't cover) and same blast radius. The 409
race guard in submitChildApproval would catch a stale-call_id POST
even without this, but rendering wrong UI until the operator clicks
is the worse failure mode.

loadChildren's finally block already fires scheduleLiveFetch for
every visible row after the replace-mode refresh, so the cache
repopulates with authoritative pending_approval_detail in one bulk
request within the next debounce window.

Plan: docs/design/inline-child-approvals.md (chunk 4 of 4 — last
required chunk; 5/6 are stretch).
2026-04-27 11:41:14 -07:00
Patrick Buckley 54f04496c3 feat(approve): inline approve/deny buttons + judge verdict pill on coord tree
Chunk 3 of the inline-child-approvals plan + the SSE pipeline plumbing
needed for sub-second urgent fetches.

JS (coordinator.js):
- approveWorkstream(targetWsId, body) — generic POST helper, callable
  for both the coord-self dock and the new per-child inline buttons.
- renderApprovalBlock(child, detail) — risk-level pill (.risk.* per
  the design system primitives), tool-name summary with "+ N more"
  for envelope-level approvals, intent_summary, ↳ judge reasoning
  teaser, ▸ more disclosure carrying the recommendation chip,
  evidence list, and items 2..N stacked sub-blocks. Plus matrix
  coverage: judge_pending / judge unavailable / tool-policy
  blocked / multi-item.
- submitChildApproval — handles the 409 stale call_id race by
  invalidating the live cache + urgent-refetching, optimistically
  clears pending_approval_detail on success.
- scheduleLiveFetch({ urgent: true }) — bypasses the 5s TTL +
  cancels the debounce so attention transitions surface inline UI
  immediately instead of after the next polling window.
- handleChildState fires urgent on activity_state="approval"
  enter/leave; handleChildClosed eagerly invalidates the live
  cache so closed rows can't render stale buttons.

CSS (index.html):
- New .approval-block / pill / preview / actions / disclosure
  styles. Inline .act buttons duplicate the dock's colour treatment
  (the dock-scoped rules don't reach the children-tree). Mobile
  <700px touch targets ≥44px.

Pipeline (collector.py + coordinator_adapter.py):
- All three cluster_state event emitters and the child_ws_state
  re-emit now carry activity_state. The previous omission left
  the urgent-fetch trigger as dead code — discovered in review.

Tests:
- Static smoke test in test_coordinator_page.py asserting the new
  helper names exist + the pending_approval_detail key is read.

Plan: docs/design/inline-child-approvals.md (chunk 3 of 4).
2026-04-27 11:41:14 -07:00
Patrick Buckley 7d2d7db9d2 feat(approve): pass pending_approval_detail through cluster live-bulk
Threads the field added by Chunk 1 through the console's live-bulk
endpoint so coord tree UI can read it without a separate per-child
fetch. Three touchpoints:

- _CLUSTER_WS_LIVE_KEYS gains the new key so _fetch_live_block's
  projection forwards it from the upstream /dashboard response on
  node-backed child rows.
- _coordinator_live_snapshot synthesizes the same shape from
  ConsoleCoordinatorUI._pending_approval for in-process coord
  rows (no upstream /dashboard exists on the console pseudo-node).
- One source of truth: SessionUIBase.serialize_pending_approval_detail.

Both branches now emit the same 12-key live block; coord judge isn't
wired today so coord-self judge_verdict is always None — flagged in
the plan as a stretch follow-up.

Plan: docs/design/inline-child-approvals.md (chunk 2 of 4).
2026-04-27 11:41:14 -07:00
Patrick Buckley fbb9be27f9 feat(approve): expose pending_approval_detail on /dashboard + guard stale call_id
Lays the server-side groundwork for inline approve/deny buttons + judge
verdict on the coordinator children-tree UI. Two surgical changes:

1. SessionUIBase.serialize_pending_approval_detail() merges the active
   _pending_approval items[] with per-call_id verdicts from
   _llm_verdicts. The dashboard handler embeds this on every per-ws
   row so cluster live-bulk callers can render inline UI without an
   extra per-child round-trip.

2. make_approve_handler now returns 409 when the body sends a call_id
   that doesn't match any currently-pending item. Closes the stale
   call_id race where an operator clicks approve on a row showing
   call A while the child has rolled over to call B. Empty/missing
   call_id preserves backwards compatibility with CLI + channel
   adapters that don't track it.

Cross-tenant exposure on /dashboard is consistent with the trusted-team
posture already in place for activity / tokens — documented in the new
method's docstring so the choice survives the next reviewer.

Plan: docs/design/inline-child-approvals.md (chunk 1 of 4).
2026-04-27 11:41:14 -07:00
renovate[bot] 5ebee015d2 chore(deps): lock file maintenance 2026-04-27 07:50:27 -07:00
Patrick Buckley b8e51fa9ed fix(api): add DequeueRequest schema for DELETE /workstreams/{ws_id}/send
Copilot review on PR #422 flagged that the DELETE-on-send (dequeue)
EndpointSpec declared no request_model, so the generated OpenAPI
showed no requestBody for an operation that *requires* a JSON body
with ``msg_id`` and 400s when it's missing.

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

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

4558 tests passing under ``-m "not live"``; ruff + mypy clean.
2026-04-26 22:14:22 -07:00
Patrick Buckley 5874159ffd fix(close): require non-empty body, restore CloseWorkstreamRequest
Copilot caught three real issues in PR #422 review, all clustered
around the close request body contract:

1. The interactive close handler runs with
   ``supports_close_reason=True``, which calls
   ``read_json_or_400(request)`` — an empty / non-JSON body returns
   ``400 {"error": "Invalid JSON body"}``. The previous SDK fix
   sent NO body via ``json_body=None``, which would 400 against a
   real server. The mock-transport test silently masked it because
   the mock answered without inspecting the body.
2. The doc said the body was empty (or ``{}``), with no mention
   of the optional ``reason`` field, its 512-byte cap, or the
   credential-redaction guard.
3. The Pydantic schema for close was deleted outright; OpenAPI
   and SDKs lost their typed shape for the optional ``reason``.

Changes:

- ``turnstone/api/server_schemas.py``: reintroduce
  ``CloseWorkstreamRequest`` with a single optional
  ``reason: str | None = None`` field. Docstring documents the
  must-be-valid-JSON contract and notes that coord ignores the body
  (``supports_close_reason=False``).
- ``turnstone/api/server_spec.py``: re-import the schema, point the
  close ``EndpointSpec`` at it via ``request_model=``, restore the
  ``_ALL_MODELS`` entry. OpenAPI JSON regenerated.
- ``turnstone/sdk/server.py``: ``close_workstream`` (sync + async)
  gains an optional ``reason: str | None = None`` parameter and
  always sends ``json_body={}`` (or ``{"reason": ...}``) so the
  body is never empty. Adds a regression test
  (``test_close_workstream_sends_valid_json_body``) that inspects the
  raw transport content rather than relying on a path-keyed mock —
  the kind of check that would have caught this bug pre-merge.
- ``sdk/typescript/src/server.ts``: ``closeWorkstream`` gains an
  optional ``opts.reason`` parameter; reintroduce
  ``CloseWorkstreamRequest`` interface in ``types.ts`` and re-export
  from ``index.ts``.
- ``docs/api-reference.md``: close section documents the JSON-body
  requirement, the ``reason`` field, the 512-byte cap, the
  multibyte-safe behavior, the credential-redaction guard, and the
  non-string-coercion path.
- ``CHANGELOG.md``: amend the 1.5.0 BREAKING block to reflect the
  schema reintroduction (slim form, ``reason`` optional) instead of
  the prior "removed outright" claim.

4558 tests passing under ``-m "not live"`` (was 4557 — +1 from the
regression test). ruff + mypy clean.
2026-04-26 22:14:22 -07:00
Patrick Buckley d6e615d324 fix: apply /review feedback on legacy URL cleanup
Reviewer caught real misses on the consumer-swap claim:

- TypeScript SDK still defined and re-exported `CloseWorkstreamRequest`
  (types.ts + index.ts) — drop both. Now matches the Python-side
  removal.
- Four `tests/test_auth.py` cases (`test_write_full_token_ok`,
  `test_approve_full_token_ok`, `test_bearer_takes_precedence_over_cookie`,
  `test_cookie_full_on_write_ok`) were tautological after the legacy
  URL removal: they posted to `/api/send` / `/api/approve` and asserted
  `allowed is True`, but those paths now classify as `read` so a read
  token would also pass — they no longer tested the write/approve
  scope enforcement. Swap to path-keyed URLs to restore the original
  intent.
- `is_public_path("/api/send")` test renamed + retargeted to a
  path-keyed URL.

Doc-table drift the previous commit missed:

- `docs/security.md` path-to-scope mapping rewritten for the
  path-keyed verb family (write set, DELETE-on-/send dequeue,
  per-ws_id approve).
- `docs/architecture.md` scope-model row text swap from `/api/send`
  / `/api/approve` to the path-keyed equivalents.
- `docs/diagrams/01-system-context.puml` channel→server edge label
  swap.
- `docs/diagrams/15-auth-architecture.puml` scope class swap.

Cosmetic comment-only stragglers:

- `tests/test_session_worker.py` module docstring URL update.
- `tests/test_ratelimit.py` ~11 `/api/send` fixture-key strings
  retargeted to `/api/workstreams/abc/send` so the URL fixtures
  reflect the post-1.5 surface (rate limiter is path-agnostic; the
  swap is purely cosmetic).

4557 tests still passing under -m "not live"; ruff + mypy clean.
2026-04-26 22:14:22 -07:00
Patrick Buckley ad0e7ce6eb docs: mark 1.5.0 legacy URL surface removal
CHANGELOG [Unreleased] / Removed (BREAKING — 1.5.0) block calling out
the legacy URL family removal with the swap table. Doc passes on
api-reference.md (per-endpoint sections rewritten with path
parameters and slimmer body shapes), architecture.md (handler-list
diagram and console-proxy URL example), console.md (URL-rewriting
JS shim docstring + SSE proxy example), and the two PlantUML
diagrams (11-console-data-flow, 16-channel-architecture).

Also picks up two test-side stragglers from step 5 that referenced
the legacy adapters in a docstring + a stale /v1/api/events SSE
test: turn into path-keyed equivalents. OpenAPI JSON dump regenerated
to reflect the catalog edits from step 3.

After this commit:
- 4557 tests passing under -m "not live"
- ruff + mypy clean on turnstone/ tests/ sdk/
- grep for "/v1/api/send", "/v1/api/approve", "/v1/api/cancel",
  "/v1/api/workstreams/close" returns zero hits across turnstone/
  sdk/ docs/ tests/ (excluding CHANGELOG.md, which intentionally
  documents the old shape).
- grep for make_legacy_body_keyed_adapter, make_legacy_query_keyed_adapter,
  _make_method_dispatch, close_legacy returns zero hits.
2026-04-26 22:14:22 -07:00
Patrick Buckley 1358121d52 chore(tests): refresh fixtures for path-keyed URL family
Mechanical updates across the test suite to swap legacy
/v1/api/{send,approve,cancel,events,workstreams/close} URLs for the
path-keyed equivalents under /v1/api/workstreams/{ws_id}/<verb>, and
to drop ws_id from request bodies (the path provides it now).

Per file:

- test_session_routes.py: deletes test_close_legacy_mounts_when_handler_provided
  (the close_legacy slot is gone); test_send_mounts_post_and_delete_when_dequeue_provided
  (added in PR commit 1) stays.
- test_openapi.py: expected-paths set swaps to path-keyed shape;
  test_send_endpoint_has_request_body now asserts the OpenAPI for
  /v1/api/workstreams/{ws_id}/send.
- test_auth.py / test_auth_identity.py: required_scope and
  check_request fixtures swap to path-keyed shape; new tests cover
  write/approve/read scope assignment for the path-keyed verbs +
  the /node/* proxy mirror.
- test_sdk_server.py / test_sdk_console.py: mock-transport URL keys
  swap; bodies drop ws_id.
- test_server_attachments_endpoints.py: ~17 send sites migrated to
  /v1/api/workstreams/<ws>/send (a small Python script ran the bulk
  rewrite — body ws_id stripped, URL rebuilt).
- test_server_authz.py: cross-tenant approve/close/cancel/events
  tests retargeted to path-keyed URLs;
  test_events_legacy_query_keyed_url_still_resolves_to_404_for_unknown_ws
  renamed to test_events_path_keyed_url_resolves_to_404_for_unknown_ws
  with the docstring updated to note the legacy adapter is gone.
- test_close_reason_persistence.py: 7 close sites all swap.
- test_console_routing_proxy.py: route-proxy tests swap to
  /v1/api/route/workstreams/{ws_id}/<verb>; the upstream-URL
  assertion now reads from .request (route_proxy uses
  client.request(method, url, ...) for method passthrough); _wire_proxy
  helper installs both .post and .request mocks for compatibility.
- test_route_proxy_audit.py: parametrized URLs migrated;
  _make_proxy now also exposes a .request side-effect that delegates
  to .post for the same compatibility surface.
- test_api_versioning.py: openapi.json path assertion swaps to the
  path-keyed shape.

4557 passing under -m "not live"; ruff + mypy clean.
2026-04-26 22:14:22 -07:00
Patrick Buckley 3ea6fb30b4 refactor(consumers): swap UI/SDK/console-proxy/channels to path-keyed URLs
All in-tree consumers of the legacy /v1/api/send | /approve | /cancel |
events?ws_id= | /workstreams/close URLs now hit the path-keyed shape
under /v1/api/workstreams/{ws_id}/<verb>. Bodies drop ws_id (the path
provides it). The SSE event stream URL likewise moves to the path-keyed
form; channel adapters drop the params={"ws_id": ...} kwarg on
aconnect_sse.

Touched:

- turnstone/ui/static/app.js: 7 call sites (send×3, dequeue, approve,
  cancel, close + EventSource SSE URL).
- turnstone/sdk/server.py (Python SDK): close_workstream, send,
  approve, cancel, stream_events, send_and_wait's internal SSE
  consumer.
- sdk/typescript/src/server.ts: closeWorkstream, send, approve,
  cancel, streamEvents + sendAndWait's internal SSE consumer.
- turnstone/sdk/console.py: route_send, route_approve, route_close,
  route_cancel — proxy URLs swap to /v1/api/route/workstreams/{ws_id}/<verb>.
  route_plan_feedback / route_command remain body-keyed (out of scope).
- turnstone/console/server.py:
  - Proxy mount table swaps the four legacy /api/route/{send,approve,
    cancel,workstreams/close} mounts for path-keyed equivalents under
    /api/route/workstreams/{ws_id}/<verb>; /send accepts both POST
    and DELETE for dequeue.
  - route_proxy reads ws_id from path_params (with body-fallback for
    the surviving plan/command body-keyed mounts), uses
    client.request(request.method, ...) so DELETE on /send proxies
    through correctly, and audits DELETE-on-/send as a separate
    "route.workstream.dequeue" action via _ROUTE_PROXY_AUDIT_ACTIONS.
  - Internal `method` variable renamed to `verb` to avoid confusion
    with HTTP method now that the two diverge.
- turnstone/channels/_sse.py: SSE URL builder swaps to path-keyed.
- turnstone/channels/{discord,slack}/bot.py: docstring URL updates.
- turnstone/server.py, turnstone/core/session_worker.py,
  turnstone/sdk/events.py, turnstone/api/server_spec.py: comment /
  docstring URL updates only.

Test fixtures still reference legacy URLs and will be swapped in step
5 of this PR.
2026-04-26 22:14:22 -07:00
Patrick Buckley 41e83f98d6 refactor(auth,api): drop legacy paths from scope tables, slim verb schemas
- WRITE_PATHS / APPROVE_PATHS in turnstone/core/auth.py drop the four
  legacy literal entries (/api/send, /api/cancel, /api/workstreams/close,
  /api/approve). The path-keyed verb match for write expands from
  {delete, open, refresh-title, title, attachments} to also include
  {send, cancel, close}; a sibling branch maps POST /workstreams/{ws_id}/approve
  to the approve scope, and a DELETE branch maps DELETE
  /workstreams/{ws_id}/send (dequeue) to write. The /node/* proxy
  block mirrors all four expansions so the console routing proxy
  stays in lockstep.
- server_schemas.py drops the body-keyed ws_id field from SendRequest,
  ApproveRequest, CancelRequest. CloseWorkstreamRequest deleted in
  full (its only field was ws_id, now provided by the path).
- server_spec.py: drops CloseWorkstreamRequest from imports and
  _ALL_MODELS, swaps the five legacy EndpointSpec entries to their
  path-keyed equivalents (POST/DELETE workstreams/{ws_id}/send, POST
  /approve, POST /cancel, POST /close, GET /events). Catalogue retains
  /api/plan and /api/command unchanged (out of scope).

Tests still reference the legacy URLs and will fail at this commit;
test fixture updates land in step 5 of this PR. Step 4 swaps the
UI / SDK / console proxy / channels callers next.
2026-04-26 22:14:22 -07:00
Patrick Buckley da12c6b268 refactor(routes): drop legacy body-keyed and query-keyed URL adapters
Removes the pre-1.5 interactive URL family that mounted body- and
query-keyed shapes on top of the lifted path-keyed handlers via
make_legacy_body_keyed_adapter / make_legacy_query_keyed_adapter.
Path-keyed equivalents under /v1/api/workstreams/{ws_id}/<verb>
already serve every consumer; coord never used the legacy URLs.

Removed:

- make_legacy_body_keyed_adapter / make_legacy_query_keyed_adapter
  from turnstone/core/session_routes.py.
- _make_method_dispatch from turnstone/server.py (zero callers
  after legacy /api/send POST+DELETE block goes — its only purpose
  was to bridge that single dual-method legacy URL).
- 5 legacy Route mounts in turnstone/server.py:
  /api/events?ws_id, /api/send POST+DELETE, /api/approve, /api/cancel,
  /api/workstreams/close.
- close_legacy field on SharedSessionVerbHandlers and its mount in
  register_session_routes — the only surviving body-keyed slot in
  the registrar, no longer needed.

Tightened make_dequeue_handler to read ws_id from the path only;
the body-fallback existed solely for the legacy DELETE /api/send
path and is now dead.

Test-suite updates and consumer call-site swaps (UI / SDK /
console proxy / channels) follow in subsequent commits in the same
PR — main stays broken across this commit until step 4 lands.
External SDK consumers on stable 1.0/1.3/1.4 calling these URLs
will receive 404s on upgrade to 1.5.0; CHANGELOG breaking-change
call-out lands with the docs commit.
2026-04-26 22:14:22 -07:00
Patrick Buckley 2b435263e3 refactor(routes): wire DELETE on path-keyed workstreams/{ws_id}/send
Pre-flight for the legacy URL adapter removal: the path-keyed
`/v1/api/workstreams/{ws_id}/send` route only mounted POST today;
the dequeue handler was reachable only via the legacy
`DELETE /v1/api/send` body-keyed URL through `_make_method_dispatch`.

Add a new `dequeue: Handler | None = None` slot on
`SharedSessionVerbHandlers` next to `send`, mounted as a second
`Route` on the same path with `methods=["DELETE"]` (two distinct
Routes rather than collapsing methods on one Route — different
handler callables, and collapsing would force the same
method-dispatch wrapper this cleanup is tearing out).

Wire `dequeue=dequeue_handler` in `turnstone/server.py`'s
`SharedSessionVerbHandlers(...)` call so DELETE on the path-keyed
shape works in the same merge as the legacy mount removal.

Adds a regression-locking test covering both the POST+DELETE and
the dequeue-alone cases.
2026-04-26 22:14:22 -07:00
Patrick Buckley fef266dbd9 docs: apply Copilot review feedback on PR #421
Switch fenced-code language tag from `json` to `http` on the seven
example blocks that mix an HTTP request line with a JSON body
(/trust, /restrict, /stop_cascade, /close_all_children, /approve,
/cancel, /close). Pure JSON response blocks stay tagged `json`.

Pre-existing pattern in the doc that Copilot flagged on the lines
this PR touched; fixed across all instances for consistency. No
content / URL changes — only fence-tag adjustment for correct
syntax highlighting.
2026-04-26 20:00:45 -07:00
Patrick Buckley 059bbc3729 docs: update coord URL tree to post-Stage-2 unified /v1/api/workstreams
The Stage 2 verb-shape lift converged coord and interactive on the
unified /v1/api/workstreams/{ws_id}/<verb> URL tree; the
/v1/api/coordinator/* tree was removed in P0. Two docs still
documented the pre-lift surface:

- coordinator-api-tour.md (the integrator's lifecycle walk-through):
  rewrites all 9 step URLs to the post-lift paths, keeps a one-block
  callout noting the historical /v1/api/coordinator/* tree and why
  it converged, and drops the operation-id column (operation ids
  shifted with the URL move and are now best looked up live via
  /openapi.json + Swagger UI rather than baked into prose).
- bulk-endpoints.md (the cascade-mutation shape contract): two table
  rows for stop_cascade / close_all_children fixed.

No code changes. CHANGELOG entry kept implicit since this is doc-only
and the URL convergence itself was already documented under the P0
verb-lift CHANGELOG block.
2026-04-26 20:00:45 -07:00
Patrick Buckley 6572437c5d refactor(server): rename dashboard row id → ws_id for v1 row-shape consistency
The /v1/api/dashboard endpoint was the last workstream-listing surface
keyed on `id` rather than `ws_id`. The Stage 2 list-verb lift converged
the active list (`/v1/api/workstreams`) and saved list
(`/v1/api/workstreams/saved`) on `ws_id` but explicitly left dashboard
alone to keep that PR's diff focused. This lands the same rename on
the remaining endpoint so v1 row shape is consistent across the family.

Scope kept narrow:

- Pydantic `DashboardWorkstream` and TS SDK `DashboardWorkstream`
  interface both rename `id: str/string` → `ws_id`.
- The bundled web UI (`turnstone/ui/static/app.js`) is the only consumer
  reading `dashboard.workstreams[].id` and is updated atomically.
- Console `_fetch_live_block` (cluster-inspect's projection over a
  remote node's dashboard payload at `turnstone/console/server.py`)
  flips its `entry.get("id")` lookup to `entry.get("ws_id")`.
- Drive-by: stale `id` example in `docs/api-reference.md` for the
  earlier `/v1/api/workstreams` rename also fixed.

`_build_node_snapshot` (the global-events SSE node_snapshot payload
consumed by the cluster collector) deliberately stays on `id` — it's
part of a separate cluster-row family (collector → cluster_workstreams
→ console UI) that is internally consistent on `id` and would need its
own coordinated sweep. CHANGELOG documents the bounded blast radius.

Tests: 4554 passing (-m "not live"). ruff + mypy clean.
2026-04-26 20:00:45 -07:00
Patrick Buckley 3abd2c441b feat(console): coord rich ws_state payload + live activity broadcast (#420)
* feat(console): coord rich ws_state payload + live activity broadcast (Stage 2 follow-up)

Pre-lift coord's cluster broadcast was state-only — the dashboard's
coord rows showed the state column flipping but ``tokens`` /
``context_ratio`` / ``activity`` / ``content`` were all hardcoded
to zero / empty. The lift makes coord populate the same per-ws
metric fields interactive does and broadcasts them through the
cluster collector with the rich kwargs.

**Architecture changes:**

- Lift ``on_status`` / ``on_content_token`` / ``on_thinking_start`` /
  ``on_thinking_stop`` / ``on_stream_end`` / ``on_tool_result`` /
  ``on_reasoning_token`` / ``on_tool_output_chunk`` / ``on_info`` /
  ``on_error`` from ``WebUI`` to :class:`SessionUIBase` as base
  implementations. Coord inherits the bodies; the per-ws metric
  fields it had at the base but never populated now flow.
- ``WebUI`` keeps overrides for ``on_status`` / ``on_tool_result`` /
  ``on_error`` to layer Prometheus ``_metrics.record_*`` calls
  on top of ``super()`` (node-only — the console isn't a node).
  ``WebUI._broadcast_state`` now uses the new
  :meth:`SessionUIBase.snapshot_and_consume_state_payload` helper
  for the rich-payload snapshot read.
- ``ConsoleCoordinatorUI`` adds a ``_broadcast_activity`` override
  that calls the new
  :meth:`ClusterCollector.update_console_ws_activity` (in-memory
  pseudo-node row update; named ``update_*`` rather than ``emit_*``
  to flag the no-fanout asymmetry vs. the rest of the
  ``emit_console_ws_*`` family).
- ``coord_adapter.emit_state`` reads ``ws.ui``'s snapshot under
  ``_ws_lock`` and passes the rich kwargs to the extended
  :meth:`ClusterCollector.emit_console_ws_state`. Defensive when
  ``ws.ui is None`` mid-eviction (broadcasts state-only).
- ``coord_endpoint_config`` wires a new ``_coord_spawn_metrics``
  hook so per-spawn ``_ws_messages`` / ``_ws_turn_tool_calls``
  bookkeeping fires on coord too.
- ``_MAX_TURN_CONTENT_CHARS`` moved from ``turnstone.server`` to
  ``turnstone.core.session_ui_base`` so coord enforces the same
  per-turn content cap.

**Three observable behaviour changes** (CHANGELOG-callout-worthy):

- Coord persists ``usage_event`` storage rows on every status
  emission (governance dashboards / token-spend queries gain
  coord visibility).
- Coord broadcasts live activity transitions to the cluster
  collector (dashboard's coord rows show activity ticks between
  state changes the same way interactive does), with last-emitted
  dedup so a tool-heavy turn's repeated ``activity=""`` clears
  don't hammer the collector lock.
- Cluster ``cluster_state`` events for coord rows now carry
  non-zero ``tokens`` / ``content``. Frontend rendering that
  conditionally hid these on coord can drop the branch.

**Tests:** 23 new tests in ``tests/test_coord_rich_ws_state_payload.py``
(per-ws metric writes, snapshot helper drain semantics +
single-lock-acquisition, adapter rich-payload pass-through +
None-UI defensive handling, activity broadcast wire + dedup +
failure swallow + no-op-when-collector-unset, spawn_metrics
hook, concurrent-writes-during-snapshot stress with reader
cycling through running/idle/error so drain branches actually
run, on_stream_end activity-clear pin). Plus WebUI override
regression tests confirming ``_metrics.record_*`` still fires
on top of the lifted bodies. Existing
``tests/test_webui_content.py`` updated to import
``_MAX_TURN_CONTENT_CHARS`` from its new home;
``tests/test_coordinator_adapter.py`` updated to expect the
rich-payload kwargs (default zeros) on
``emit_console_ws_state``. Total: ``4491 → 4514``.
``ruff check`` clean, ``mypy`` clean on touched files.

**/review pipeline** (4 finders → verify → dedupe) caught 14
findings → 12 unique (3 collapsed as duplicates of the lockless
``on_content_token`` writer):

- bug-1 Minor: ``on_status`` regressed coord's defensive
  ``usage.get(...)`` indexing → restored ``.get(..., 0)`` for
  ``prompt_tokens`` / ``completion_tokens`` on both base + WebUI
  override.
- bug-2 Nit: concurrent-snapshot reader only used ``"running"`` →
  cycled through ``("running", "idle", "error")`` so drain
  branches run; also captures + re-raises thread exceptions
  instead of silently passing.
- bug-3 + sec-2 + perf-3 Nit (merged): ``on_content_token``
  mutated ``_ws_turn_content`` lockless while the snapshot drained
  under lock → wrapped the cap-check + append + size-update in
  ``_ws_lock``.
- perf-2 Minor: collector lock contention from per-event activity
  broadcasts → cached last-emitted ``(activity, activity_state)``
  on the UI; subsequent identical ticks return early without
  acquiring the collector lock.
- perf-4 Nit: join-under-lock in snapshot helper → swap-then-join
  pattern (capture list reference under lock, reassign to empty,
  join the captured list outside the lock). Halves the lock
  hold and decouples the join walk from concurrent appenders.
- q-1 Minor: ``emit_console_ws_activity`` was misleading (no
  ``_fanout`` call, unlike the rest of the ``emit_console_ws_*``
  family) → renamed to ``update_console_ws_activity`` + docstring
  call-out for the asymmetry.
- q-2 + q-3 Minor/Nit: stale docstrings on
  ``coordinator_ui.py`` (still claimed "no per-node metrics —
  Phase D") and ``_interactive_spawn_metrics`` (still claimed
  "counters live on WebUI only") → both updated to reflect the
  lifted base class + coord's new hook.
- q-4 Nit: broken Sphinx cross-ref
  ``:meth:\`_snapshot_and_consume_state_payload\``` → dropped
  the leading underscore.
- q-5 Nit: missing ``test_coord_on_stream_end_clears_activity``
  → added.

**Two findings explicitly deferred** (out-of-scope follow-ups,
documented in CHANGELOG):

- perf-1: synchronous ``record_usage_event`` INSERT on coord
  worker thread per status tick. Parity with WebUI is the lift's
  goal; if throughput becomes a concern, batch usage_event writes
  on a background flusher (would apply to both kinds).
- sec-1: coord assistant content now flows on the cluster SSE
  stream, which has no per-user filter today. Pre-existing
  exposure for interactive ``cluster_state`` events; the lift
  extends to coord rows. Proper fix needs SSE auth gating
  (``admin.cluster.inspect``) or per-listener user_id filtering
  — separate security project, doesn't gate this lift.

* fix(console): apply review feedback on PR #420

Three review findings, all confirmed against source:

1. **Copilot — dedup-state-vs-failure race in `_broadcast_activity`**
   (correctness bug): pre-fix ``self._last_broadcast_activity = current``
   was assigned inside the ``_ws_lock`` block BEFORE the collector call.
   If the collector raised mid-broadcast, the exception was swallowed
   but the dedup state was already updated, so subsequent identical
   activity ticks would be deduped and never retried — leaving the
   dashboard's coord row stranded at the pre-failure activity until
   the activity actually changed.

   Fix: move the dedup-state update OUT of the lock and place it AFTER
   a successful collector call. On failure, ``_last_broadcast_activity``
   stays unchanged so the next identical tick retries. Two new
   regression tests pin both the failure-recovery (``test_coord_ui_
   broadcast_activity_failure_does_not_strand_dedup``) and the
   happy-path dedup behavior (``test_coord_ui_broadcast_activity_
   dedup_skips_identical_after_success``).

2. **Copilot — stale `emit_console_ws_activity` reference in
   CHANGELOG**: the method was renamed to ``update_console_ws_activity``
   per /review's q-1 finding before the original commit landed, but the
   CHANGELOG entry was written ahead of the rename. Updated to match
   the actual API + added the no-fanout asymmetry rationale inline so
   readers don't have to chase the method name.

3. **code-quality bot ×2 — `except BaseException` in test workers**:
   the concurrent-snapshot stress test caught thread-worker exceptions
   with ``except BaseException`` (with a noqa to suppress BLE001).
   ``BaseException`` is overkill for a thread worker — ``SystemExit``
   / ``KeyboardInterrupt`` are main-thread signals and ``Exception``
   is the right scope. Narrowed to ``except Exception`` on both
   workers; ``writer_exc`` / ``reader_exc`` types narrowed from
   ``list[BaseException]`` to ``list[Exception]``.

Tests: ``4514 → 4516`` (+2 regression tests for the dedup race fix).
``ruff check`` clean, ``mypy`` clean. No code-path changes outside
the dedup-state placement; the rich-payload broadcast surface is
unchanged.
2026-04-26 17:32:44 -07:00
Patrick Buckley acbe18d5f5 docs: apply Copilot review feedback on PR #419
Server-side history endpoint declared ``error_codes=[404]`` but the
lifted ``make_history_handler`` factory can also return:

- ``400`` on empty ``ws_id`` (defensive — Starlette routing makes
  it unreachable in practice, but the factory has the branch).
- ``500`` on the ``cfg.list_kind is None`` misconfig gate added in
  the /review fix-up (defense-in-depth fail-loud; both production
  cfgs wire ``list_kind`` so the gate doesn't fire today).
- ``503`` via ``cfg.manager_lookup`` when the kind's manager isn't
  available (interactive's lookup never returns 503; coord's can).

Updated ``server_spec.py`` to ``[400, 404, 500, 503]`` per Copilot's
suggestion — matches the existing detail entry's shape so the two
endpoints document the same possible-error envelope.

Caught the parallel asymmetry on ``console_spec.py``: history was
``[403, 404, 503]`` but the lifted factory's misconfig + empty-
ws_id branches reach coord too. Updated to
``[400, 403, 404, 500, 503]`` — same factory body, same possible
responses, plus ``403`` from coord's ``admin.coordinator``
permission gate.

Regenerated ``openapi-{server,console}.json``. No code changes;
spec metadata only. Tests + lint + mypy unchanged.
2026-04-26 15:48:26 -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 e8a6b0632d docs: apply Copilot review feedback on PR #418
Three docstring + CHANGELOG drift items from the post-review
M3 + Mi1 fixes:

- ``make_list_handler`` docstring referenced ``cfg.list_resolve_title``
  (singular) but the field renamed to ``list_resolve_titles``
  (bulk variant) when the N+1 fix landed. Updated to the plural
  name + a one-line note about the bulk SELECT pattern.
- ``make_saved_handler`` docstring still claimed kind was derived
  from ``cfg.audit_action_prefix`` string-compare. The Mi1 fix
  replaced that with the explicit ``cfg.list_kind`` field +
  fail-loud-on-missing semantic; docstring now describes the
  current contract.
- CHANGELOG ``[Unreleased]`` entry said "Three new
  ``SessionEndpointConfig`` fields" and listed the singular
  ``list_resolve_title`` wired to ``get_workstream_display_name``.
  Updated to "Four" + the bulk plural names + the new
  ``list_kind`` field with its rationale (distinct from
  ``audit_action_prefix``; fail-loud on misconfig).

The fourth review comment — code-quality bot flagging the ``...``
ellipsis body on the new ``get_workstream_display_names`` Protocol
method as "statement has no effect" — is a false positive.
``...`` is the canonical Protocol method body throughout
``turnstone/core/storage/_protocol.py`` (every other method uses
it). Refuting; the file's pattern wins over the bot's per-method
suggestion.

No code changes; docstring + CHANGELOG only. Tests + lint + mypy
unchanged.
2026-04-26 13:11:07 -07:00
Patrick Buckley edf52016ac refactor(core): lift list + saved verb bodies across both kinds (Stage 2 verb lift)
New ``make_list_handler(cfg)`` and ``make_saved_handler(cfg)``
factories in ``turnstone/core/session_routes.py`` replace four
pre-lift bodies (interactive ``list_workstreams`` +
``list_saved_workstreams``; coord ``coordinator_list`` +
``coordinator_saved``). Same factory + capability-flag pattern as
the merged cancel / open / events / create lifts.

Four new ``SessionEndpointConfig`` fields:

- ``list_resolve_titles: ListResolveTitles | None`` — bulk lookup
  ``(ws_ids) -> {ws_id: title-or-None}``. Interactive wires
  ``get_workstream_display_names`` (new bulk helper added on the
  storage layer + memory.py); the lifted body resolves every active
  row in ONE ``SELECT ... WHERE ws_id IN (...)`` instead of the
  pre-lift N+1 (one SELECT per row).
- ``list_kind: WorkstreamKind | None`` — explicit kind classifier
  for the saved-list storage filter. Replaces the initial draft's
  ``audit_action_prefix == "coordinator"`` string compare which
  would have silently leaked INTERACTIVE rows for any future kind
  whose audit prefix didn't match. Required when a kind mounts
  list/saved; misconfig surfaces as a 500 with a clear log line.
- ``saved_state_filter: str | None`` — coord wires ``"closed"``;
  interactive wires ``None``.
- ``saved_loaded_lookup: SavedLoadedLookup | None`` — coord-only
  defence-in-depth filter that excludes ws_ids in the warm pool.

Behaviour changes (all observable in CHANGELOG):

- **Active-list row shape converges on always-include** ``{ws_id,
  name, state, kind, parent_ws_id, user_id}``. Interactive renames
  ``id`` → ``ws_id``; both kinds populate every field (coord adds
  kind + parent_ws_id; interactive adds user_id).
- **Top-level response key converges on ``"workstreams"``** on
  both endpoints. Coord ``coordinators`` key removed — coord is a
  1.5.0aN-only surface (never shipped stable) so the convergence
  has no compat shim; SDK / frontend consumers swap once.
- **Storage + manager-lock work moved off the event loop on
  interactive**. ``list_workstreams_with_history`` runs through
  ``asyncio.to_thread`` on both kinds (matches coord's pre-existing
  perf-2 pattern from the saved-coordinators review); ``mgr.list_all``
  + per-row work also offloaded.
- **N+1 storage round-trips on /v1/api/workstreams eliminated**.
  Pre-lift interactive resolved the alias for every active row in a
  separate SELECT (up to 50 round-trips per dashboard refresh on a
  saturated node). Lifted body issues one bulk SELECT.

Pydantic schemas: ``WorkstreamInfo.id`` renamed → ``ws_id``,
``WorkstreamInfo.user_id`` field added. ``CoordinatorInfo`` and
``CoordinatorListResponse`` removed (folded into the unified
``WorkstreamInfo`` / ``ListWorkstreamsResponse``). OpenAPI spec
snapshots regenerated. TS SDK types updated (``WorkstreamInfo``
interface gains ws_id + the always-include fields); TS test
mock + assertion updated to match.

``GET /v1/api/dashboard`` is intentionally NOT in this PR's scope
and still returns rows keyed on ``id``. Tracked as a separate
cleanup PR (tombstone-note added at the dashboard handler).

/review pipeline run; the four Major findings + one Minor + six
nits all addressed in the same commit:

- M1: TS SDK ``WorkstreamInfo`` interface stale (id: string) →
  renamed + fields added.
- M2: TS SDK test masked the type-mismatch with stale mock → updated.
- M3: N+1 alias resolution on active list → bulk
  ``get_workstream_display_names`` helper + ``list_resolve_titles``
  bulk cfg hook.
- M4: Missing interactive parity regression test for unified row
  shape → mirror of coord's added in test_server_authz.py.
- Mi1: ``audit_action_prefix`` string-compare deriving kind →
  explicit ``cfg.list_kind: WorkstreamKind`` field.
- Six nits: redundant inner asyncio import, forward-ref quotes on
  Awaitable, duplicated frontend comments, dashboard ``id`` field
  has no tombstone-note, empty-coord_mgr short-circuit on
  ``saved_loaded_lookup``.

4512 tests passing; ruff + mypy clean.
2026-04-26 13:11:07 -07:00
Patrick Buckley c77b237033 refactor(core): defer emit_created on SessionManager.create + commit_create / discard pair (#417)
* refactor(core): defer emit_created on SessionManager.create + commit_create / discard pair

Eliminates the phantom create→close pair on coord rollback that was
documented as a known limitation in PR #416. The pair surfaced on the
cluster events stream when a multipart workstream-create request
failed attachment validation: coord's ``mgr.create`` fired
``emit_created`` synchronously, then the rollback called
``mgr.close`` which fired ``emit_closed``. Cluster consumers had to
reconcile via the collector's diff path. Post-fix, a rejected upload
produces zero events.

API changes on ``SessionManager``:

- ``create(..., defer_emit_created: bool = False)`` — when True,
  skip the trailing ``emit_created`` so the caller can run additional
  post-create work (attachment validation in the lifted HTTP handler)
  before advertising the workstream. Default preserves the existing
  "advertise immediately" contract for direct callers (test fixtures,
  CLI REPL, channel adapters).
- ``commit_create(ws)`` — fires the deferred ``emit_created`` event
  after the caller's post-create work confirms the workstream should
  be advertised. Synchronous; the wrapped work is in-memory and
  non-blocking on every kind (interactive: documented no-op stub;
  coord: dict updates under a lock + ``queue.put_nowait`` fan-out).
- ``discard(ws_id)`` — releases the in-memory slot + cleans up the UI
  WITHOUT firing ``emit_closed``. Distinct from ``close`` which
  advertises the transition; ``discard`` is for the rollback case
  where the workstream's existence was never advertised. Storage-row
  deletion stays a separate concern (caller invokes
  ``delete_workstream``), mirroring ``mgr.create``'s split between
  slot reservation and ``register_workstream``.

Caller-bug detection: ``Workstream._emit_created_fired`` is set
inside ``create`` (non-deferred path) and ``commit_create``;
``discard`` logs ``session_mgr.discard.after_emit_created`` warning
when invoked on an already-advertised workstream. Slot is still
released so capacity isn't stranded.

Lifted ``make_create_handler`` updated to use the deferred bracket:
pass ``defer_emit_created=True``, validate uploaded attachments,
then ``mgr.commit_create(ws)`` on success / ``mgr.discard(ws.id)``
on failure. Ordering invariants (``commit_create`` BEFORE
``audit_emit`` and ``post_install`` so any state events the worker
fires reach the cluster collector for an already-known ws_id) are
documented in the handler docstring.

Tests:

- 5 new ``SessionManager`` unit tests (defer skips emit, commit
  fires it, commit no-ops without emitter, discard releases without
  emit_closed, discard returns False on unknown id).
- 2 caller-bug regression tests (commit_create after discard pins
  the silent re-emit behaviour; discard after non-deferred create
  asserts the warning fires + slot still releases).
- 1 coord regression test asserting the cluster collector sees zero
  events when attachment validation fails.

``/review`` pipeline run; M1 (test gap on caller-bug paths) +
Mi1 (no runtime guard for already-advertised) + Mi2
(``_make_manager`` event_emitter override) + Mi3 / N2 (duplicated
comments + ordering invariant) + N1 (drop ``to_thread`` on
``commit_create``) all addressed.

4509 tests passing; ruff + mypy clean.

* fix(core): apply Copilot + code-quality review feedback on PR #417

Copilot review:

- ``Workstream._emit_created_fired`` comment claimed the flag was
  "set under the manager's _lock-protected emit", but the actual
  ordering set it OUTSIDE the lock. Comment updated to describe the
  real synchronization (non-deferred ``create`` sets it immediately
  before ``emit_created``; ``commit_create`` sets it under the
  manager lock alongside the tracked-ws check).
- ``commit_create`` had no guard against duplicate calls,
  post-discard calls, or calls on workstreams not tracked by this
  manager — any of those would have fired duplicate or phantom
  ``ws_created`` events. Added a guard symmetric to ``discard``'s
  after-emit warning: under ``self._lock``, check ``_emit_created_fired``
  + ``_workstreams.get(ws.id) is ws``, no-op + log a warning
  (``session_mgr.commit_create.already_fired`` /
  ``session_mgr.commit_create.untracked``) on either failure. The
  emit itself still runs outside the lock so coord's collector
  fan-out doesn't couple to the manager mutex.
- ``test_commit_create_after_discard_is_caller_bug_no_op`` was
  internally inconsistent — name + docstring said "must not re-emit"
  but the assertion expected the re-emit. Renamed to
  ``test_commit_create_after_discard_is_no_op`` and updated to
  assert the new no-op + warning behaviour.

New test ``test_commit_create_is_idempotent_on_duplicate_call``
pins the second-commit-call code path: exactly one ``ws_created``
event fires, second call short-circuits via the guard with a
``commit_create.already_fired`` warning.

Code-quality bot review (3 findings, identical pattern):

- Three test ``assert`` statements wrapped side-effecting calls
  (``assert mgr.discard(ws_id) is True/False``); under ``python -O``
  the asserts strip and the side-effect strips with them. Refactored
  all three to assign the result to a local first, assert on the
  local. No behaviour change.

4510 tests passing; ruff + mypy clean.
2026-04-26 12:06:59 -07:00
Patrick Buckley 16916dc257 fix(core,console): coord create-time attachments coordination + Copilot review feedback on PR #416
Coord initial-message + create-time-attachments coordination:

- ``CoordinatorAdapter.send`` gains optional ``attachments`` + ``send_id``
  kwargs so the worker dispatched at create time can carry the uploaded
  files onto the first turn. Mirrors interactive's pre-existing
  worker-thread pattern. The ``send_id`` reservation token soft-locks
  the rows; the adapter's failure path unreserves so a worker crash
  returns them to pending.
- ``_coord_create_post_install`` reserves any uploaded ``attachment_ids``
  via the lifted ``reserve_and_resolve_attachments`` helper before
  dispatching through the adapter — closes the parity gap with
  interactive's create-with-attachments+initial_message flow.
- ``_reserve_and_resolve_attachments`` lifted from ``turnstone/server.py``
  to ``turnstone/core/attachments.py`` as ``reserve_and_resolve_attachments``
  so both processes use one kind-agnostic implementation.

Copilot review fixes on PR #416:

- Skill lookup now calls ``storage.get_prompt_template_by_name`` directly
  rather than going through ``turnstone.core.memory.get_skill_by_name``;
  that helper swallows storage exceptions into ``None`` which would have
  masked outages as the 400 "Skill not found" branch. Calling storage
  directly lets exceptions bubble to the lifted body's correlation_id'd
  500 path so operators chasing skill-related reports can distinguish
  real misses from registry outages.
- ``_interactive_create_build_kwargs`` /
  ``_coord_create_build_kwargs`` thread ``skill_data["name"]`` (the
  canonical row name) into ``mgr.create`` instead of the raw
  ``body["skill"]`` value. Pre-fix a whitespace-padded request body
  ``"skill": "  my-skill "`` would have persisted the dirty name even
  though the lookup ran on the stripped key.
- ``make_create_handler`` docstring corrected: audit-emit failures
  return 200 (not 201).
- ``_audit_workstream_created`` docstring corrected: factory keeps the
  successful 200 response on audit-emit failure (was 201).

New regression test:
``test_create_with_multipart_attachments_and_initial_message_reserves``
asserts attachments are reserved (not pending) when both
``initial_message`` and uploads land in the same coord create request.
Updated ``_SendSession`` stub in ``test_coordinator_adapter.py`` to
match the new ``send`` / ``queue_message`` signatures.

4501 tests passing; ruff + mypy clean.
2026-04-26 04:07:39 -07:00
Patrick Buckley 9ed8b1e0b5 refactor(core): lift create verb body across both kinds (Stage 2 verb lift)
New ``make_create_handler(cfg, *, audit_emit=None)`` factory in
``turnstone/core/session_routes.py`` consumes five new ``SessionEndpointConfig``
fields (``create_supports_attachments``, ``create_supports_user_id_override``,
``create_validate_request``, ``create_build_kwargs``, ``create_post_install``)
and replaces both ``create_workstream`` and ``coordinator_create`` bodies.
Same factory + capability-flag pattern as the merged cancel / open / events
lifts. ``_validate_and_save_uploaded_files`` lifted to
``turnstone.core.attachments`` so both processes call one kind-agnostic
implementation.

Coord parity gains (§ Post-P3 reckoning item #1 + carry-forward):
- Create-time attachments: multipart parsing, validate+save+rollback,
  ``attachment_ids`` on the response. Coord adapter ``send`` doesn't yet
  reserve attachments at create time, so the rows save as pending and the
  next ``/send`` picks them up via the standard send-with-attachments path.
- Disabled-skill rejection (matches interactive's pre-lift gate).
- Always-include response shape ``{ws_id, name, resumed, message_count,
  attachment_ids}`` populated with default ``False``/``0``/``[]`` on the
  fields coord doesn't fill.
- 200 status (was 201).
- Audit-emit failures swallow + warning log instead of 500.

Both kinds converge on the manager-at-capacity 429, factory-misconfig 503,
and correlation_id'd 500 for unexpected ``mgr.create`` failure (interactive
lifted up to coord's safer error envelope).

Three /review fixes folded in:
- ``notify_targets`` malformed input gates at the validator (400) instead
  of bubbling out of post_install as a 500 — pre-fix the workstream had
  already been created + audited + broadcast by the time the validation
  raised.
- Skill-lookup storage failures now share the correlation_id'd 500 path
  with ``mgr.create`` (was masquerading as 400 "Skill not found").
- Whitespace-only ``skill`` field treated as empty (matches pre-lift coord).

CHANGELOG entry under [Unreleased] documents every observable behaviour
change. OpenAPI spec regenerated. Three new coord regression tests
(create-time-attachments save pending rows, always-include parity fields,
disabled-skill rejection) plus one interactive regression test
(notify_targets 400). 4500 tests passing.
2026-04-26 04:07:39 -07:00
Patrick Buckley 577ad2824f refactor(core): lift events verb body across both kinds (Stage 2 verb lift) (#415)
* refactor(core): lift events verb body across both kinds (Stage 2 verb lift)

The interactive ``GET /v1/api/events?ws_id=...`` and coord
``GET /v1/api/workstreams/{ws_id}/events`` SSE handlers now share
one body via ``make_events_handler(cfg)``. Per-kind divergence
captured by two new ``SessionEndpointConfig`` fields:

* ``events_replay: EventsReplay | None`` — Protocol-typed callback
  that yields the kind-specific initial replay payload. Interactive
  wires ``_interactive_events_replay`` (connected + status + history
  + pending_approval + cached intent verdicts + pending_plan_review);
  coord wires ``_coord_events_replay`` (just pending_approval +
  pending_plan_review). The lifted body iterates the callback
  before starting the live event loop.
* ``sse_executor_lookup: SseExecutorLookup | None`` — per-kind
  executor for the live loop's blocking ``client_queue.get``.
  Interactive returns the dedicated 200-thread ``sse_executor``
  from app state so SSE polling stays isolated from every other
  ``asyncio.to_thread`` caller in the process; coord returns
  ``None`` and the lifted body falls through to the default executor.

Also adds ``make_legacy_query_keyed_adapter(handler)`` (sister to
``make_legacy_body_keyed_adapter`` from earlier lifts): reads
``ws_id`` from the query string and splices into ``request.path_params``
before delegating to the lifted body. Preserves the
``GET /v1/api/events?ws_id=...`` legacy URL shape so any 1.x SDK
consumer keeps working.

Old ``events_sse`` (server.py) + ``coordinator_events``
(console/server.py) bodies deleted.

Two convergence wins for coord:

* **SSE connect/disconnect metrics** — pre-lift coord didn't record
  per-stream metrics; the lifted body always calls
  ``metrics.record_sse_connect()`` / ``record_sse_disconnect()``,
  giving the cluster dashboard the same per-stream observability
  interactive's had since 1.0.
* **Both kinds now check ``request.is_disconnected()`` AND the
  ``ws_closed`` event** to terminate. Pre-lift interactive relied
  solely on ``ws_closed`` (which never fires if the client just
  goes away without closing the workstream); pre-lift coord relied
  solely on ``is_disconnected``. The lifted body uses both.

One observable shape change for coord callers: the lifted body
returns 409 ``"session has no UI"`` when ``ws.ui`` is missing
(placeholder / build-failed UI), matching pre-lift coord.
Pre-lift interactive returned 404 in this case; the lift converges
on 409 because the workstream EXISTS (404 would imply it doesn't).

Item #2 from § Post-P3 reckoning (rich ``ws_state`` payload parity
for coord) split out during scoping — touches different files
(``coordinator_ui.py`` + ``collector.py`` + ``session_ui_base.py``)
with different reviewer concerns. Tracked as standalone follow-up
``feat/coord-rich-ws-state-payload``.

Two /review fixes folded in:

* **Dedicated SSE thread pool restored.** Initial draft used
  ``asyncio.to_thread`` (default executor, ~32 workers). Pre-lift
  interactive deliberately used a dedicated 200-thread
  ``sse_executor`` to avoid pool starvation; the
  ``sse_executor_lookup`` cfg field above restores that isolation.
* **5s poll timeout restored.** Initial draft shortened to 1s,
  multiplying thread-wakeup rate 5x while the pool was already
  starving. ``is_disconnected()`` between polls covers cancel-
  detection latency.

Plus minor cleanups: stale ``coordinator_events`` comment
references in coordinator.js refreshed; ``TestInteractiveEventsLifted``
gets a ``_make_interactive_replay_mocks`` fixture so per-test
intent stays clear; live-loop coverage gap documented in the
test class docstring.

Lint + mypy clean. 4497 tests passing (+8 new events tests).

* fix(core): stream events replay from inside the generator instead of pre-building

PR #415 review caught that ``make_events_handler`` pre-built the
full replay payload (``connected`` + ``status`` + ``history`` +
pending prompts) into a list before constructing the
``EventSourceResponse``. Two real costs:

* **TTFB delay** — the client saw nothing until the heaviest
  replay event finished serialising (``_build_history`` on a
  long-running interactive workstream can take 10s of ms). With
  pre-build, the ``connected`` event was buried at the end of
  the materialisation pass instead of streaming first.
* **Listener-queue accumulation** — registering the per-UI
  listener BEFORE building the replay let live events queue
  during the build window. On a chatty mid-generation
  workstream that window can fill the 500-slot listener queue
  and drop events before the live loop starts draining.

Fix: iterate ``cfg.events_replay`` inside the async generator
so each event ships as soon as the callback yields it. The
observational-failure swallow semantics are preserved by
wrapping the iteration in the same try/except + log.debug as
before — partial replay is still acceptable; the live loop
continues either way.

Resolves the Copilot review thread on PR #415. Lint + mypy
clean. 4497 tests passing (no test changes — the replay
callbacks themselves are unchanged; only the lifted body's
consumption pattern flipped from eager-build to lazy-stream).
2026-04-26 01:58:41 -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 412c99f486 refactor(core): lift cancel verb body across both kinds (Stage 2 verb lift) (#413)
* refactor(core): lift cancel verb body across both kinds (Stage 2 verb lift)

The interactive ``/v1/api/cancel`` (body-keyed ws_id) and coord
``/v1/api/workstreams/{ws_id}/cancel`` (path-keyed) handlers now
share one body via ``make_cancel_handler(cfg, *, audit_emit=None)``
in ``turnstone.core.session_routes``. Per-kind divergence captured
by a new ``cancel_forensics: CancelForensics | None`` field on
``SessionEndpointConfig`` (interactive wires
``_capture_cancel_forensics``; coord wires ``None``) plus an
optional ``audit_emit`` (coord wires ``_audit_cancel_coordinator``;
interactive wires ``None`` — pre-lift interactive didn't audit
cancel).

Same factory + capability-flag pattern as P1.5's ``make_send_handler``
+ make_attachment_handlers. Old ``cancel_generation`` body deleted
from ``server.py``; old ``coordinator_cancel`` body deleted from
``console/server.py``.

Behavior changes (documented in CHANGELOG):

* **Coord gains the ``force`` flag.** Pre-lift coord ignored
  ``force``; the lifted body honours it on both kinds. Stuck-worker
  recovery becomes available on coord (parity gain — coord workers
  hang the same way interactive's can).
* **Coord cancel response always includes ``"dropped"``.** Pre-lift
  returned bare ``{"status": "ok"}``; lifted returns
  ``{"status": "ok", "dropped": {}}``. Always-include parity with
  interactive so SDK consumers don't branch on kind.
* **Coord cancel returns 400 ``"No session"``** on placeholder /
  build-failed workstreams (was a silent 200 no-op pre-lift). Parity
  with interactive's existing 400 branch.
* **Coord ``coordinator.cancel`` audit detail now includes
  ``force``** so operator-driven recovery is distinguishable from
  routine cancels.

Three /review fixes folded in:

* **bug-1**: lifted body's ``resolve_approval`` is now gated on
  ``ui._pending_approval is not None``. Pre-fix, the unconditional
  call leaked a stale ``approval_resolved`` SSE event on every
  idle cancel — listener UIs that key on the event would dismiss
  prompts they didn't have. ``resolve_plan`` keeps its existing
  internal no-pending guard so the unconditional call is still
  safe there.
* **bug-2**: force-cancel now clears ``_worker_running`` alongside
  ``worker_thread`` inside the same ``with ws._lock`` block. Prior
  half-state ``(_worker_running=True, worker_thread=None)`` routed
  follow-up sends through the queue-enqueue path onto the abandoned
  worker (whose cancel flag short-circuits the queue-drain seam,
  leaving messages orphaned until next spawn). Restores the
  ``(worker_thread, _worker_running)`` invariant
  ``session_worker.send`` documents.
* **bug-3**: ``coordinator_stop_cascade._fanout_on_children`` now
  treats child cancel ``400 + "No session"`` as ``skipped`` (was
  ``failed``). Lifted coord cancel returns 400 on placeholder
  children; matches the pre-lift outcome where those children were
  silently no-op'd, so the cascade response's ``failed`` bucket
  stops firing spurious operator alerts.

Test scaffolding:

* ``tests/test_coordinator_endpoints.py`` — replace ``coordinator_cancel``
  fixture with ``make_cancel_handler(...)`` wiring; add 6 new
  tests covering always-include shape, force-flag worker-abandon,
  400-on-null-session, cancel_forensics swallowed-exception,
  audit_emit swallowed-exception, no-stale-approval-resolved-on-idle.
* ``tests/test_server_authz.py`` — new ``TestInteractiveCancelLifted``
  class with HTTP-level coverage of ``/v1/api/cancel`` for the
  dropped shape, force-flag + ``_worker_running`` clearing, and
  400-on-null-session. Pre-lift ``cancel_generation`` had no
  HTTP-level test; this is the first.

One observable change for interactive (pre-existing call site):
``resolve_approval`` / ``resolve_plan`` now run on every cancel
regardless of ``was_running`` (was gated). Lifts coord's
unconditional behaviour onto interactive — a stuck approval-pending
state from a crashed worker can now be cleared via cancel without
requiring close + rehydrate.

Lint + mypy clean. 4484 tests passing (was 4475; +9 new cancel
tests minus the moved one that became part of the new suite).

* docs(core,changelog): correct cancel-lift behaviour description for resolve_approval

Two review comments on PR #413 caught the same drift between the
implementation and its documentation: my bug-1 fix gated
``resolve_approval`` on ``_pending_approval is not None`` (because
it broadcasts ``approval_resolved`` unconditionally), but the
``make_cancel_handler`` docstring and the CHANGELOG entry still
claimed both ``resolve_approval`` and ``resolve_plan`` "run on
every cancel" and "the calls are idempotent and no-op when
nothing is blocked".

Reality:

* ``resolve_plan`` does run on every cancel and its no-op-when-
  nothing-pending behaviour is real (the method has an internal
  ``_pending_plan_review is None`` short-circuit).
* ``resolve_approval`` runs only when ``ui._pending_approval is
  not None``. Without the gate, every idle cancel would broadcast
  a stale ``approval_resolved`` SSE event and overwrite
  ``_approval_result``.

Updated:

* ``make_cancel_handler`` docstring (turnstone/core/session_routes.py
  in the "Behavior changes vs the pre-lift handlers" section) —
  splits the two methods into separate bullets, explains why
  ``resolve_approval`` is gated and ``resolve_plan`` isn't.
* CHANGELOG.md ``[Stage 2 Verb Lift — cancel]`` entry — same
  split + rationale; the asymmetric coord pre-lift parity is
  still flagged as the recovery path that drove the lift.

Docs-only change; lint + mypy clean; cancel test suite (59 tests)
unchanged.

* style(core): replace CancelForensics ellipsis stub with docstring

github-code-quality bot flagged the ``...`` body of
``CancelForensics.__call__`` as "Statement has no effect". The
ellipsis is the canonical Protocol method-body idiom (no real
issue), but switching to a one-line docstring satisfies the bot
AND adds a small piece of method-level documentation. The class-
level rationale (why Protocol-typed instead of a plain Callable
alias) moves from a wall of leading ``#`` comments into a proper
class docstring at the same time.

Style-only change; the Protocol semantics are identical.
2026-04-25 23:43:19 -07:00
Patrick Buckley 48c9ad2a40 refactor(core): split SessionKindAdapter Protocol into construction +… (#412)
* refactor(core): split SessionKindAdapter Protocol into construction + emission (Stage 2 P3)

The single ``SessionKindAdapter`` Protocol that ``SessionManager``
takes is split into two:

* ``SessionKindAdapter`` — kind / build_ui / build_session /
  cleanup_ui. Required for every kind. The shared lifecycle
  manager always delegates here for construction + cleanup.
* ``SessionEventEmitter`` — emit_created / emit_state /
  emit_rehydrated / emit_closed. **Optional**, wired through a new
  ``event_emitter: SessionEventEmitter | None = None`` kwarg on
  ``SessionManager``. Reserved for future kinds whose lifecycle
  transitions don't fan out anywhere; both production kinds wire
  one today.

Both production adapters implement both Protocols. The interactive
lifespan (``server.py``) and console lifespan
(``console/server.py``) pass their adapter as both ``adapter`` and
``event_emitter`` — production behaviour is unchanged. Six lifecycle
sites in ``SessionManager`` (create / open eviction / open rehydrate /
close / set_state / close_idle / _reserve_and_install_locked unwind)
now call ``self._event_emitter.emit_*(...)`` guarded by
``if self._event_emitter is not None``.

InteractiveAdapter asymmetry preserved + documented:

* ``emit_closed`` stays load-bearing — it's the **sole** transport
  path for ``ws_closed`` onto the process-wide global SSE queue
  (Stage 1 consolidated emission from the create handler here so
  there's exactly one emission point; ``name`` powers the
  frontend's eviction toast).
* ``emit_created`` / ``emit_state`` / ``emit_rehydrated`` are
  documented no-op stubs (``del ws[, state]``). Those events fire
  from out-of-band paths — the create HTTP handler enqueues
  ``ws_created`` directly onto ``global_queue`` *after* attachment
  validation (so a rejected upload doesn't surface a phantom
  create→close pair); ``WebUI._broadcast_state`` emits the full
  ``ws_state`` payload (tokens + context_ratio + activity) via the
  ``SessionUI.on_state_change`` callback chain. The stubs exist
  solely to satisfy ``SessionEventEmitter`` Protocol so the
  adapter can be wired as the manager's ``event_emitter`` for the
  ``emit_closed`` path. Each stub has a 1-line inline rationale to
  match the in-repo convention (``coordinator_adapter.py:210``).

Test scaffolding:

* ``tests/test_session_manager.py`` — ``_make_manager`` and
  ``_make_with_writer`` wire ``FakeAdapter`` as both ``adapter``
  and ``event_emitter`` for production parity; the standalone
  ``test_create_uses_configured_node_id`` does the same.
  ``FakeAdapter.emit_rehydrated`` now records as
  ``_Event("rehydrated", ...)`` rather than conflating with
  ``"created"``, and ``test_open_resurrects_closed_state`` asserts
  against ``events_of("rehydrated")`` so a regression where the
  manager fires the wrong call on the open path actually fails.
* ``tests/_coord_test_helpers.py`` and
  ``tests/test_coordinator_end_to_end.py`` — wire
  ``CoordinatorAdapter`` as both args.
* Six interactive test fixtures (``test_skills.py``,
  ``test_prompt_templates_runtime.py`` x2, ``test_model_registry.py``,
  ``test_server_authz.py``, ``test_server_attachments_on_create.py``)
  — wire ``event_emitter=adapter`` so they match the production
  wiring, removing the footgun where a future contributor adds a
  ``gq.get_nowait()`` assertion and silently loses the only
  ``ws_closed`` transport.
* ``tests/test_interactive_adapter.py`` — drops the three
  tautological no-op-emit_* tests (``test_emit_created_is_noop``,
  ``test_emit_state_is_noop``, ``test_emit_rehydrated_is_noop``);
  keeps the four ``emit_closed`` tests (real behaviour).

Lint + mypy clean. 4475 tests passing.

* docs(core): correct SessionKindAdapter + SessionEventEmitter docstrings to match implementation

Two Copilot review threads on PR #412 caught the same real
discrepancy: my P3 docstrings on ``SessionKindAdapter`` and
``SessionEventEmitter`` described an *intent* — "interactive
doesn't implement ``SessionEventEmitter``; the manager skips emit
calls when no emitter is wired" — that doesn't match the actual
wiring. ``InteractiveAdapter`` does implement both Protocols and
``server.py`` does pass it as ``event_emitter``; only the three
no-op stubs (``emit_created`` / ``emit_state`` / ``emit_rehydrated``)
are dead, while ``emit_closed`` is load-bearing.

Updated both docstrings to:

* State that both production adapters implement both Protocols.
* Explain the asymmetry is in *which* emit methods carry real
  bodies (coord: 4; interactive: 1, with 3 documented stubs because
  the out-of-band paths — create handler ``ws_created`` after
  attachment validation, ``WebUI._broadcast_state`` carrying the
  richer ``ws_state`` payload — fire those events).
* Clarify the ``if self._event_emitter is not None`` guard exists
  for the kwarg-omitted case (tests that don't care about events,
  reserved for future kinds whose transitions don't fan out
  anywhere).

Docstring-only change. Lint + mypy clean; the 75 tests in
test_session_manager + test_interactive_adapter + test_coordinator_adapter
pass.

Resolves the two Copilot review threads on PR #412 (commits
PRRC_kwDORcMomM67VyPD, PRRC_kwDORcMomM67VyPI).
2026-04-25 22:52:39 -07:00
Patrick Buckley 02e4a01207 fix(core,server): apply Copilot review feedback on PR #411
Five fixes from Copilot's review of Stage 2 P1.5 — all preserve
behaviour, narrow docstring claims, and round out the response shape:

* **session_routes.py:supports_attachments docstring** — claimed
  the handler "accepts only ``{"message": ...}``" when ``False``,
  but the implementation silently ignores ``attachment_ids``
  rather than rejecting. Updated wording to say the
  attachment-resolution block short-circuits and any
  ``attachment_ids`` are silently ignored. Behaviour unchanged
  (silent-ignore is the right choice for forward compat — clients
  passing ``attachment_ids`` speculatively to a not-yet-lit-up
  kind shouldn't get a 400).

* **session_routes.py:queue_full response shape** — restored the
  always-include guarantee for ``attached_ids`` /
  ``dropped_attachment_ids``. The queue_full path now returns
  ``attached_ids: []`` and ``dropped_attachment_ids: list(requested_ids)``
  so SDK consumers don't have to branch on status.

* **server.py:_interactive_spawn_metrics guard** — added
  ``_ws_turn_tool_calls`` to the ``hasattr`` chain. Previously
  the guard checked ``_ws_lock`` + ``_ws_messages`` and then
  unconditionally assigned ``_ws_turn_tool_calls`` — would
  raise on a SessionUI subclass with the first two but not the
  third.

* **console_spec.py:coord_send error_codes** — added 409
  (the 'session UI not available' branch in
  ``make_send_handler`` returns 409, but the spec didn't list
  it). OpenAPI spec regenerated; TS SDK types refreshed.

* **session_routes.py:tenant_check docstring** — claimed
  interactive uses ``_require_ws_access`` with "404 on owner
  mismatch", but the helper now delegates to
  ``resolve_workstream_owner`` which explicitly does NOT enforce
  row-level ownership (trusted-team semantics; 404s only on
  missing rows). Updated wording to match.
2026-04-25 21:44:40 -07:00
Patrick Buckley ad56192a96 fix(core,console): address /review feedback on Stage 2 P1.5
Six fixes from the local /review pipeline (find-bug + find-security +
find-quality, all confirmed by verify):

* **sec-1 (major)** — coord ``attachment_owner_resolver`` now
  resolves through ``coord_mgr.get(ws_id)`` only and does NOT fall
  back to storage. Without the kind-strict check, an
  ``admin.coordinator``-scoped caller could pass an *interactive*
  workstream ws_id to the new coord attachment endpoints; the
  generic ``get_workstream_owner`` storage call (kind-agnostic)
  would resolve and grant cross-kind read / write access to
  interactive attachments. New regression test
  ``test_coord_attachment_endpoints_404_on_interactive_ws_id``
  pins the surface.

* **bug-1 (minor)** — UI hook calls in the spawn-path ``_run``
  closure are now wrapped per-hook (via ``_emit_ui``) so a failure
  in ``ui.on_error`` doesn't suppress the subsequent
  ``ui.on_stream_end`` / ``ui.on_state_change`` calls. Mirrors the
  pre-P1.5 coord_adapter.send per-hook defense.

* **bug-2 (minor)** — ``make_dequeue_handler`` now 404s when
  ``ws.ui is None`` (preserves the pre-P1.5 ``_get_ws`` contract;
  a partially-constructed or close-window workstream shouldn't
  answer DELETE).

* **bug-3 (minor)** — ``coordinator.js`` gains a
  ``case "message_queued":`` handler that surfaces the queueing
  as an info row. Coord wires ``emit_message_queued=True`` for
  parity with interactive but the dashboard had no router branch
  for these events, silently dropping them.

* **bug-4 (minor)** — error-message format on coord regressed
  from ``f"{type(exc).__name__}: {exc}"`` to ``f"Error: {e}"``
  (lost the exception class name, which coord operators rely on
  to triage failures). Restored.

* **q-1 (major)** — duplicate ``_auth_user_id`` and
  ``_require_ws_access`` helpers in ``server.py`` and
  ``console/server.py`` now delegate to the lifted
  ``turnstone.core.web_helpers.auth_user_id`` /
  ``resolve_workstream_owner``. The lifted versions are the
  canonical implementations; the shims keep existing call sites
  working without a sweeping rename.

CHANGELOG entry adds a Security section noting the kind-strict
resolver fix and a behaviour callout for the cancel-state semantic.
2026-04-25 21:44:40 -07:00
Patrick Buckley e0c78e2aec test,docs: coord attachment + queue parity tests + spec regen + CHANGELOG
Five new TestCoordinatorAttachments tests in
``tests/test_coordinator_endpoints.py`` exercising the lifted
attachment surface end-to-end on coord:

* upload → list round-trip
* get_content returns raw bytes with text/plain forced for text
* delete removes pending entries and clears them from the listing
* send with attachment_ids consumes pending under the send_id token
* send response carries attached_ids / dropped_attachment_ids even
  on plain-text sends (unified shape parity)

The existing ``_coord_endpoint_config`` fixture grew capability
flags to mirror the production console wiring, and ``_make_client``
now mounts the four coord attachment routes via
``make_attachment_handlers``.

OpenAPI specs regenerated; TS SDK bumped to 0.5.0. CHANGELOG entry
under [Unreleased] documents the verb-shape lift, the coord
attachment surface coming online, the response-shape change for
``coordinator_send``, the unification of the three lifted classifier /
lock helpers under ``turnstone.core.attachments``, and the new SDK
helpers.
2026-04-25 21:44:40 -07:00
Patrick Buckley 61fe759b6c refactor(server,console): wire both kinds to lifted send/attachments factories
Replaces per-kind ``send_message`` / ``coordinator_send`` and the
four interactive attachment handlers with calls to the shared
factories from ``turnstone.core.session_routes``. Net deletion of
~660 LOC from ``server.py`` (the lifted body lives in
``session_routes`` and is mounted twice — once interactive, once
coord).

Interactive (``turnstone/server.py``):

* ``SessionEndpointConfig`` now carries ``supports_attachments=True``,
  ``attachment_owner_resolver`` (delegates to ``_require_ws_access``
  via storage path to preserve test fixtures using MagicMock
  managers), ``attachment_helpers`` (the lifted classifiers +
  upload-lock), ``spawn_metrics`` (records the per-conversation
  WebUI counters that coord doesn't have), and
  ``emit_message_queued=True``.
* New ``_make_method_dispatch`` adapter lets the legacy body-keyed
  ``/v1/api/send`` URL serve both POST (send) and DELETE (dequeue)
  via the lifted handlers.
* The four attachment handler bodies (``upload_attachment`` etc.)
  are deleted; the shared registrar mounts them via
  ``make_attachment_handlers(cfg)``.

Coord (``turnstone/console/server.py``):

* Same wiring with coord-specific resolvers
  (``_coord_attachment_owner`` via the lifted
  ``resolve_workstream_owner``). ``spawn_metrics=None`` since the
  coord dashboard doesn't have per-conversation counters; cluster
  metrics fan out via the collector.
* Old ``coordinator_send`` body deleted.
* Console-side coord attachment endpoints come up automatically
  through the shared ``AttachmentHandlers`` slot — no per-kind
  attachment handler bodies needed at all.

Coord dashboard (``coordinator.js``): user messages with
attachments arriving on history replay now extract just the text
portion + a ``📎 N attachment(s)`` count badge instead of
JSON-stringifying the multipart content. Full chip-rendering with
click-to-view stays deferred.

Python SDK adds coord-side helpers on
``AsyncTurnstoneConsole`` + ``TurnstoneConsole``:
``coordinator_send`` (with ``attachment_ids``),
``coordinator_upload_attachment``,
``coordinator_list_attachments``,
``coordinator_get_attachment_content``,
``coordinator_delete_attachment``. URL prefix is direct
``/v1/api/workstreams/`` since coord workstreams live on the
console — no routing-proxy hop needed.

Behaviour change for coord callers:

* Worker-queue-full responses are now ``200 {"status": "queue_full"}``
  for parity with interactive (was ``429 {"error": "..."}``). SDK
  consumers checking for 429 should switch to the status field.
* Send response now always carries ``attached_ids`` /
  ``dropped_attachment_ids`` (empty arrays on plain text sends);
  the live-worker reuse path also surfaces ``priority`` /
  ``msg_id``.
2026-04-25 21:44:40 -07:00
Patrick Buckley 3398c4b6e7 feat(core): lift send + attachments to shared factories with capability flags
Stage 2 P1.5 — verb-shape unification at the HTTP layer for both
``send`` and the four attachment endpoints. New factories in
``turnstone.core.session_routes``:

* ``make_send_handler(cfg)`` — single body covering the
  attachment-resolution dance, dispatcher hand-off, queue/spawn
  outcome surfacing, and metrics increment. Capability flags on
  ``SessionEndpointConfig`` (``supports_attachments``,
  ``attachment_owner_resolver``, ``attachment_helpers``,
  ``spawn_metrics``, ``emit_message_queued``) toggle the per-kind
  bits without forking the body.
* ``make_dequeue_handler(cfg)`` — DELETE branch (cancel a queued
  message by ``msg_id``). Path-keyed; mountable on both new
  ``/v1/api/workstreams/{ws_id}/send`` and the legacy body-keyed
  ``/v1/api/send`` URL via ``make_legacy_body_keyed_adapter``.
* ``make_attachment_handlers(cfg)`` — quartet of upload / list /
  get_content / delete with shared scope checks and 404 masking.
  Per-kind classification + locking comes in via the new
  ``AttachmentUploadHelpers`` bundle so the cfg stays declarative.

Three pure helpers (``sniff_image_mime``,
``classify_text_attachment``, ``upload_lock``) moved from
``turnstone/server.py`` to ``turnstone/core/attachments.py`` so the
console process can wire them into the lifted attachment endpoints
without depending on the node-side server module. Behaviour is
unchanged.

``turnstone.core.web_helpers`` gains ``auth_user_id`` and
``resolve_workstream_owner`` so both kinds share the owner-resolution
helper underpinning attachment scoping. The interactive ``trusted-team``
404-on-missing semantics are preserved; ``not_found_label`` is
parameterised so coord can return ``coordinator not found``.

Console spec adds ``CoordinatorSendResponse`` (parity with interactive
``SendResponse``) and four new endpoint declarations for the coord
attachment surface.
2026-04-25 21:44:40 -07:00
Patrick Buckley a8cd9444b1 fix(server): apply Copilot + code-quality review feedback
PR #410 review pass:

* **session_worker**: ``except BaseException`` → ``except Exception``
  in ``_runner`` (code-quality bot). Daemon threads don't receive
  SystemExit/KeyboardInterrupt, so the wider catch was unjustified
  defensive style. Same defense-in-depth for unexpected ``run()``
  exceptions; doesn't widen scope to runtime signals.

* **session_worker**: ``threading.Thread()`` construction moved
  inside the spawn branch under ``ws._lock`` (Copilot). The
  enqueue path no longer allocates and then discards a Thread
  object on each call against a busy workstream. Thread()
  construction is microsecond-cheap, so the lock-window growth is
  negligible vs. the saved allocation churn.

* **lifespans**: ``state_writer.shutdown()`` (and the console
  equivalent) now run via ``asyncio.to_thread`` so the daemon-
  thread join + sync DB drain don't block the event loop and
  delay other teardown tasks (Copilot, ×2).

* **tests**: five remaining ``writer._flush_once()`` calls
  switched to the public ``writer.flush()`` API across
  test_session_manager.py (4) and test_state_writer.py (1)
  (Copilot, ×5). Tests no longer depend on private internals.
2026-04-25 20:11:47 -07:00
Patrick Buckley 52e09e87d6 fix(core): address /review feedback on Stage 2 P1
Six fixes from the local /review pipeline (find-bug + find-perf +
find-quality, all confirmed by verify):

* **bug-1 (critical)** — ``StateWriter.record(flush_now=True)`` now
  drops any pending buffered transient for the same ws_id AND waits
  on the flush_lock before its sync UPDATE. Without this, an
  earlier buffered 'running' could flush AFTER the sync 'error'
  write and clobber the terminal state — same shape as the
  close-vs-buffered-transient race ``discard`` was already
  guarding. New regression tests cover both the drop and the
  in-flight wait.

* **bug-3 (major)** — ``session_worker.send`` now assigns
  ``ws.worker_thread = t`` AND sets ``ws._worker_running = True``
  under the same ``ws._lock`` acquisition. Previously
  ``worker_thread`` was assigned outside the lock, so a reader
  holding ``ws._lock`` could observe ``_worker_running=True``
  paired with a stale (already-exited) ``worker_thread`` —
  defeating every ``ws.worker_thread is me`` identity check
  downstream.

* **bug-2 (major)** — rewind/retry busy gate in
  ``server.py:command`` now reads ``ws._worker_running`` instead
  of ``ws.worker_thread.is_alive()``. The is_alive() gate could
  see a stale dead thread under ws._lock while a new worker was
  in the middle of starting (post bug-3 fix the window narrows
  but the gate-mismatch was independent — ``_worker_running`` is
  the canonical gate post-Stage-2-P1).

* **perf-2 (major)** — ``StateWriter.discard`` now waits on
  ``_flush_lock`` with a 5s timeout (configurable). Without a
  bound, a stuck Postgres connection inside an in-flight flush
  would block ``close()`` and ``close_idle()`` indefinitely while
  they hold ``ws._lock`` — a system-wide hang on every close
  path. On timeout we log + proceed; the worst-case degrades to
  "buffered transient flushes shortly after sync 'closed'"
  (eventual consistency) rather than process hang.

* **q-1** — inline comments on ``run_retry`` and ``_run_initial``
  now explain why those two spawn sites don't go through
  ``session_worker.send``: retry-when-busy is a hard reject (no
  fallback queue), and init-on-create can't have a pre-existing
  worker by construction (enqueue branch is dead code). Both
  still set ``_worker_running`` + ``ws.worker_thread`` together
  under ws._lock for parity with the dispatcher.

* **q-3** — ``state_writer.discard`` callsite comments in
  ``session_manager.py`` no longer reference 'bug-3' (which lived
  only in untracked working notes). Now describe the invariant
  inline by what it prevents.

* **q-5** — ``StateWriter._flush_once`` promoted to public
  ``flush()``. Tests now drive flushes via the public API.
2026-04-25 20:11:47 -07:00
Patrick Buckley c3d24749f5 docs(changelog): note Stage 2 P1 worker dispatch + write-behind
Two new bullets under [Unreleased]:

* Worker dispatch unified — ``session_worker.send`` shared by
  interactive ``/v1/api/send``, the coord adapter, watches, retry,
  and initial-message paths. Gate is ``_worker_running`` (atomic
  under ws._lock) instead of ``Thread.is_alive()``. Closes a
  parallel-worker race that any concurrent path (watch + /send,
  retry + /send, init + /send) could trigger pre-P1.
* Buffered ``StateWriter`` for set_state — non-terminal transitions
  now show up in storage up to ~1s late (SSE consumers see them
  immediately via the adapter). Terminal ERROR + close still write
  sync; bug-3 invariant preserved via state_writer.discard before
  the sync 'closed' write.

The `/send` HTTP body convergence stays out of scope — interactive's
attachments / reservations / queue-outcome distinctions diverge from
coord's response shape too far for a clean factory split until
coord grows attachments parity (post-1.5.0).
2026-04-25 20:11:47 -07:00
Patrick Buckley 8240e32704 test(core): regression tests for state_writer + close ordering
Five new tests under ``TestSessionManagerWithStateWriter`` exercise
the bug-3 invariant under write-behind:

* set_state buffers via the writer (long flush_interval → no sync
  write until drain).
* set_state(ERROR) flushes synchronously.
* close after a buffered transient writes 'closed' as the final
  state — the buffered 'running' must NOT be flushed to storage
  AFTER close's sync 'closed' write.
* close_idle exhibits the same invariant.
* set_state arriving AFTER close short-circuits on ws._closed and
  never reaches the buffer.
2026-04-25 20:11:47 -07:00
Patrick Buckley 436ae79d19 refactor(core): wire StateWriter into SessionManager + lifespans
``SessionManager.__init__`` accepts an optional ``state_writer``;
when present, ``set_state`` for non-terminal transitions records via
the buffered writer instead of holding ``ws._lock`` across a sync DB
UPDATE. Terminal ERROR transitions still flush sync (error-surfacing
paths need durability before any observer sees the state).

``close()`` and ``close_idle()`` call ``state_writer.discard(ws_id)``
under ws._lock BEFORE their sync 'closed' write — drops any pending
buffered transient and waits on the flush_lock for any in-flight
flush to complete. Without this, a buffered 'running' could land in
storage AFTER the sync 'closed' write and resurrect the closed row
(bug-3 invariant under write-behind).

Lifespan wiring on both servers: build the StateWriter alongside the
SessionManager, ``state_writer.start()`` on enter, ``shutdown()`` on
teardown (drains any pending writes synchronously). Tests can leave
``state_writer=None`` and get the legacy direct-write behaviour.
2026-04-25 20:11:47 -07:00
Patrick Buckley 470a6af6a9 feat(core): add state_writer for buffered set_state persistence
``turnstone.core.state_writer.StateWriter`` buffers non-terminal
``update_workstream_state`` writes (last state per ws_id wins) and
flushes them on a ~1s cadence (configurable). Terminal ERROR
transitions and close()'s 'closed' write bypass the buffer.

Bounded buffer (``max_buffer=10000`` default) evicts the oldest
ws_id on insertion overflow — protects against unbounded growth
when storage is unreachable. ``discard(ws_id)`` drops any pending
buffered transition AND waits on a flush_lock for any in-flight
write to complete; this is the close-path hook that preserves the
bug-3 invariant (a closed row can't be resurrected by a buffered
transient writing AFTER close's sync 'closed').

13 unit tests cover coalescing, flush_now, bounded buffer, the
discard / in-flight-flush wait, lifecycle (start/shutdown
idempotence), wake-on-record latency, and resilience to storage
errors poisoning subsequent flushes.
2026-04-25 20:11:47 -07:00
Patrick Buckley 4e791cfb15 refactor(server): swap interactive workers to session_worker.send
Five spawn sites in turnstone/server.py now share the worker dispatch:

* ``send_message`` (``POST /v1/api/send``) — the main path. Now uses
  ``session_worker.send`` with separate ``_enqueue`` / ``_run``
  closures; the queue-vs-spawn outcome is conveyed via a captured
  ``queue_outcome`` dict so the existing response shapes
  (``status: queued`` vs ``status: ok``) survive.
* ``_make_watch_dispatch`` — watch results dispatch.
* ``run_retry`` (post-rewind) and ``_run_initial`` (initial-message
  on workstream creation) — set ``_worker_running`` directly under
  ws._lock instead of going through session_worker (their structural
  shape doesn't fit a queue-vs-spawn decision) but stay consistent
  with the shared gate so they can't race with /send into parallel
  workers.
* ``cancel_generation``'s ``was_running`` snapshot now reads
  ``_worker_running`` for parity with the dispatcher.

Pre-dispatch cancel-await also gates on ``_worker_running`` for
consistency. The ``busy_error`` /  ``status: busy`` legacy branch
(reached only when worker is alive but ws.session is None) is gone
— the new path checks ws.session up front and returns the same
500 shape.

Test fixtures in test_server_attachments_endpoints.py and
test_watch_dispatch.py updated to set ws._worker_running explicitly
(MagicMock auto-truthifies the field, which would otherwise mis-route
all idle paths into queue mode).
2026-04-25 20:11:47 -07:00
Patrick Buckley 7ffe8d1ca3 feat(core): add session_worker shared dispatch + delegate coord adapter
Introduces ``turnstone.core.session_worker.send`` — the atomic
check-and-(spawn-or-queue) decision both interactive and coordinator
HTTP paths use to drive ``ChatSession.send``. Callers pass no-arg
``enqueue`` / ``run`` closures; the shared module owns only the
``ws._worker_running`` lifecycle.

CoordinatorAdapter.send now delegates to the shared module — its
``_spawn_worker`` body is gone. Workstream._worker_running's
docstring updated to note both kinds use it post-Stage-2-P1.
2026-04-25 20:11:47 -07:00
Patrick Buckley abf7f62301 fix(server): address PR #409 review feedback
PR #409 line-level review feedback. Three of four findings valid;
the fourth (code-quality bot's "unused TYPE_CHECKING imports")
verified as false-positive — removing the imports breaks mypy on
the string-form annotations in ``ManagerLookup`` / ``TenantCheck``
/ ``CloseAuditEmitter``.

CI lint failure (ruff format on ``tests/_coord_test_helpers.py``)
addressed alongside.

Findings addressed:

- **Copilot #1** (``session_routes.py`` SessionEndpointConfig
  docstring): said the config is "stored on
  ``app.state.session_endpoint_config``" and "handler bodies pull
  this config from app.state". Stale after the previous fixup
  switched the factories to capture ``cfg`` via closure. Rewrote
  the class docstring + the lifted-handler comment block + the
  module docstring + the ``create_app`` block comments in both
  ``server.py`` and ``console/server.py``.
- **Copilot #2** (``server.py:_interactive_manager_lookup``
  docstring): referenced ``:data:SessionRouteHandlers`` which was
  renamed to ``SharedSessionVerbHandlers`` AND wasn't the right
  reference anyway — the callable matches
  ``SessionEndpointConfig.manager_lookup``. Fixed.
- **Bonus**: dropped the now-dead
  ``app.state.session_endpoint_config = ...`` assignments in both
  servers (nothing reads them since the closure-capture switch).
- **Bonus**: dropped the stale "close (interactive caps + redacts +
  persists close_reason)" entry from the deferred-verbs comment in
  ``session_routes.py`` — close was lifted in the previous commit
  and is no longer in the deferred set.
- **CI lint**: ``ruff format`` joined the
  ``MockStorage.list_services`` signature in
  ``tests/_coord_test_helpers.py`` to a single line (95 chars,
  fits the 100-char limit).

ruff + ruff format + mypy clean. 88 affected tests pass.
2026-04-24 16:56:06 -07:00
Patrick Buckley 74670cd53e refactor(server): apply 2nd-pass /review fixups
Addresses the eight verified findings from the second review pass on
the body-convergence work (one bug-flagged behavior change, one
defensive-style nit, six quality items). One quality item (q-6,
``request.scope[\"path_params\"]`` mutation in the legacy adapter)
is documented but not refactored — restructuring the lifted handler
signatures to take ``ws_id`` as an explicit param is bigger than
this fixup's scope; the adapter docstring already explains the
choice.

Findings addressed:

- **bug-1 + q-5**: hoist module-level ``log = get_logger(__name__)``
  in ``session_routes.py``; bump audit-failure log from ``debug``
  to ``warning`` (compliance signal). Document the interactive
  500-on-audit-failure → 200+log behavior change in CHANGELOG +
  in ``make_close_handler``'s docstring.
- **bug-2**: switch ``_audit_close_workstream`` to
  ``getattr(request.app.state, \"auth_storage\", None)`` for
  consistency with the upstream gate. Same fix on coord side.
- **q-1**: pass ``SessionEndpointConfig`` into
  ``make_approve_handler(cfg)`` and
  ``make_close_handler(cfg, *, audit_emit, supports_close_reason)``
  via closure capture. Removes the implicit ``app.state`` contract
  and parallels the two factory signatures. Tests + production
  wiring updated.
- **q-2**: promote ``_audit_close_coordinator`` to a module-level
  function in ``turnstone/console/server.py``. Both test fixtures
  import it instead of duplicating the body. The previous three
  near-identical implementations collapse to one.
- **q-3**: lift ``_interactive_tenant_check`` and
  ``_audit_close_workstream`` from nested ``create_app`` closures
  to module-level functions in ``turnstone/server.py``, beside the
  other ``_audit_*`` / ``_require_*`` helpers. Add
  ``_interactive_manager_lookup`` so the config doesn't need a
  lambda. ``create_app`` shrinks accordingly.
- **q-4**: merge the bottom ``if TYPE_CHECKING`` block into the
  one at the top of ``session_routes.py``.
- **q-7**: replace ``assert mgr is not None`` with
  ``mgr = cast(\"SessionManager\", mgr_opt)`` in both lifted
  handlers — survives ``python -O`` and makes the type-checker-only
  intent explicit.
- **q-8**: update ``test_coordinator_endpoints.py`` file docstring
  to mention the lifted-handler wiring.

ruff + mypy + 4366 pytest pass. Live console smoke against the
unified URLs returns 503 (no coord_mgr in smoke env) — proves the
factory-captured config is reachable + manager_lookup fires.

CHANGELOG ``[Unreleased]`` entry expanded to flag the audit-failure
swallow as an interactive behavior change alongside the existing
500→404 standardization.
2026-04-24 16:56:06 -07:00
Patrick Buckley 0ac5c75dcf docs(changelog): expand the [Unreleased] entry with body-convergence + SDK status
Adds two paragraphs:

- Notes the two verbs (``approve``, ``close``) whose bodies were
  successfully lifted into the shared registrar, plus the close-
  failure status-code standardization (500 → 404 on coord). Calls
  out the verbs whose bodies are intentionally NOT lifted, with
  the underlying reason (Priority 1 dependency, response-shape
  unification, etc.) so the next-session reader doesn't re-litigate.
- Notes the TS SDK 0.4.0 bump and the regenerated reference specs.

No code change.
2026-04-24 16:56:06 -07:00
Patrick Buckley 06c91294a4 refactor(server): lift close handler into shared session_routes body
Stage 2 Priority 0 Step 0.2 body-convergence — second verb.
``make_close_handler(audit_emit=..., supports_close_reason=...)``
factory in ``turnstone/core/session_routes.py`` produces the lifted
body; both interactive and coord pass their kind-specific audit
emitter at app construction.

The two body-keyed close URL aliases on the interactive side reach
the same lifted body:

- ``POST /v1/api/workstreams/{ws_id}/close`` (new, path-keyed)
  via ``register_session_routes(handlers.close=...)``.
- ``POST /v1/api/workstreams/close`` (legacy, body-keyed) via
  ``make_legacy_body_keyed_adapter(close_handler)``.

Coord exposes only the path-keyed shape.

Behavior gains:

- ``supports_close_reason=True`` (interactive only) keeps the 512-
  byte UTF-8 cap + credential redaction + ``workstream_config``
  persistence path. Coord stays at ``False``; if coord ever wants
  close-reason metadata, flipping the flag is a one-line change.
- ``audit_emit`` is per-kind so each owns its detail dict shape
  (``{kind, parent_ws_id, reason}`` vs ``{coord_ws_id, src}``) and
  audit action name (``workstream.closed`` vs ``coordinator.close``).
- Standardizes the close-failure status code to 404 across both
  kinds. The coord code previously returned 500 on a
  ``mgr.close()`` race-loss, which was overly pessimistic — the
  semantic is "the ws was popped between .get() and .close()", i.e.
  not-found.

Coord-side test fixtures (``test_coordinator_endpoints``,
``test_coordinator_end_to_end``) swap the imported
``coordinator_close`` for the lifted handler + a local audit_emit
adapter so the tests exercise the same code path the live console
does.

ruff + mypy + 4366 pytest pass. Live console smoke against
``POST /v1/api/workstreams/abc/close`` returns 503 (no coord_mgr
loaded in the smoke env) — proves the lifted handler is reachable
+ the manager_lookup callable fires correctly.

Two verbs converged so far (``approve`` + ``close``); the remaining
pairs (``send``, ``cancel``, ``open``, ``events``, ``create``,
``list``, ``saved``, ``history``, ``detail``) have substantive
behavior divergence that doesn't factor cleanly into the
SessionEndpointConfig + factory-handler pattern — see the
session_routes module docstring for the per-verb status.
2026-04-24 16:56:06 -07:00
Patrick Buckley 6415eeb91e refactor(server): lift approve handler into shared session_routes body
Stage 2 Priority 0 Step 0.2 body-convergence — first verb. Both
interactive ``approve`` and coord ``coordinator_approve`` handler
bodies collapse into ``make_approve_handler()`` in
``turnstone/core/session_routes.py``. Each kind sets a
``SessionEndpointConfig`` on ``app.state`` carrying the kind-
specific policies (auth gate, manager lookup, tenant check, audit
prefix, not-found label) the lifted body consults at request time.

The two interactive URLs converge:

- ``POST /v1/api/workstreams/{ws_id}/approve`` (new, path-keyed)
  reaches the lifted body directly via ``register_session_routes``.
- ``POST /v1/api/approve`` (legacy, body-keyed) keeps shipping;
  ``make_legacy_body_keyed_adapter`` peeks the body for ``ws_id``,
  splices it into ``request.path_params``, and forwards to the same
  lifted body. Frontend can keep using the legacy URL — no caller
  churn.

Coord exposes only the path-keyed shape (its URLs were experimental
in 1.5.0aN; the URL-shape commit already removed the ``coordinator/``
prefix).

Tenant-check is split out from permission-gate so interactive's
``_require_ws_access`` (404 on cross-owner) and coord's
``_require_admin_coordinator`` (cluster-wide scope) coexist without
either kind triggering the wrong gate.

Coord-side test fixture (``test_coordinator_endpoints._make_client``)
swaps the imported ``coordinator_approve`` for the lifted handler
and seeds ``app.state.session_endpoint_config`` so the tests
exercise the same code path the live console does.

Net delta: ~−25 LOC for this verb on top of the SessionEndpointConfig
+ legacy-adapter scaffolding (~80 LOC paid once). Subsequent verb
lifts amortize against that scaffolding.

ruff + mypy + 4366 pytest pass. Live console smoke against the
unified URL returns 503 (no coord_mgr loaded in the smoke env) —
proves the lifted handler is reachable + the manager_lookup callable
fires correctly.

Verbs still kind-specific (deferred — bodies have substantive
behavior divergence, not just naming): ``send`` (Priority 1
worker dispatch), ``cancel`` (interactive forensics + force flag),
``close`` (interactive close-reason cap+redact+persist), ``open``
(interactive resume vs coord rehydrate), ``events`` (different SSE
replay shapes), ``create`` (interactive attachments vs coord
initial_message), ``list`` / ``saved`` (different response keys).
2026-04-24 16:56:06 -07:00
Patrick Buckley 4a72b2ce19 build(sdk): regen openapi specs + bump TS SDK to 0.4.0
Stage 2 Priority 0 Step 0.5 follow-on. The handwritten Python
OpenAPI spec already moved to ``/v1/api/workstreams/`` in the URL
sweep commit; this just regenerates ``sdk/typescript/openapi-{server,console}.json``
from those specs so generated TS callers see the new paths.

Bumps the TS SDK to 0.4.0 to flag the URL-shape break for any
1.5.0aN-era consumer of the experimental coord client. Python SDK
needs no change — it never exposed the coord HTTP surface.

TS typecheck + 32 vitest tests pass.
2026-04-24 16:56:06 -07:00
Patrick Buckley ae8ffd4bad refactor(server): tighten registrar shape per code-review pass
Addresses the eight quality findings the per-priority /review pass
flagged on the registrar refactor. All confirmed by the verifier;
none blocking. Net −186 LOC in this fixup.

q-1, q-9: trim ``session_routes.py`` module docstring + console
``create_app`` comments to the timeless explanation. The Step 0.1 →
0.4 narrative was already stale within the PR that introduced it
(every step had landed by the final commit) and would rot further as
the body-convergence follow-on lands.

q-2, q-5: group the four attachment handlers into an
``AttachmentHandlers`` dataclass exposed as
``handlers.attachments: AttachmentHandlers | None``. The type system
now carries the all-or-none invariant; the parallel four-condition
chain + bare ValueError disappear.

q-3: drop the ``mgr`` and ``adapter`` placeholder kwargs from both
``register_session_routes`` and ``register_coord_verbs``. Pre-
threading them so a future commit avoids "callsite churn" violated
the project's "don't pre-build for the next step" norm — the
body-convergence follow-on will edit the callsites anyway. Drops
``SessionManager.adapter`` for the same reason.

q-4: drop ``SessionRouteConfig`` outright. It existed solely to
carry ``supports_legacy_close``; the registrar now mounts the legacy
close route whenever ``handlers.close_legacy is not None``, matching
the all-Optional convention used for every other handler.

q-6: move ``MockStorage`` from ``tests/test_console.py`` into the
shared ``tests/_coord_test_helpers.py`` and re-import in test_console
+ test_session_routes. No more cross-test-module import.

q-7: trim the two exhaustive route-table set-equality assertions
(``test_coord_shape_mounts_expected_verbs``,
``test_register_coord_verbs_mounts_expected_paths``); replaced with
focused ``test_attachment_routes_mount_when_quartet_provided`` and
``test_close_legacy_mounts_when_handler_provided``. The targeted
ordering tests still catch the actual registrar bugs.

q-8: delete the tombstone comment block where the legacy
``/api/coordinator/`` Routes used to be — per the user's
``feedback_no_tombstone_comments`` norm, deletions don't get
narrated inline.

q-10: rename ``SessionRouteHandlers`` → ``SharedSessionVerbHandlers``
and ``CoordVerbHandlers`` → ``CoordOnlyVerbHandlers`` so the
"shared verbs vs coord-only verbs" symmetry is visible at the type
names. Drop the back-compat aliases since nothing uses them.
2026-04-24 16:56:06 -07:00
Patrick Buckley ffac49d098 docs(changelog): flag the coord URL move under [Unreleased]
The plan called the CHANGELOG callout for the
``/v1/api/coordinator/`` → ``/v1/api/workstreams/`` move
non-negotiable since experimental SDK consumers from 1.5.0aN lose
the URL outright. Adds the path-mapping table under [Unreleased]
so the line lands in the 1.5.0 release notes when the version cuts.

Stable upgraders (1.0 / 1.3 / 1.4) never saw the coord URL prefix
so the change is a no-op for them — the entry says so explicitly.
2026-04-24 16:56:06 -07:00
Patrick Buckley df7c0c2f44 refactor(server): delete legacy /v1/api/coordinator/ URL tree
Stage 2 Priority 0 Steps 0.4–0.7 — collapses the four migration
steps into one commit since they have to land together. The legacy
``/v1/api/coordinator/`` URL prefix never shipped in a stable release
(it appeared in 1.5.0aN experimental), so there's no compat carry-
forward — just rip and replace.

What moves:

- Step 0.4: deletes the eighteen ``Route("/api/coordinator/...")``
  entries from ``console/server.py``. Coord traffic now flows
  exclusively through the unified ``/v1/api/workstreams/`` shape
  mounted via ``register_session_routes`` + ``register_coord_verbs``
  (Steps 0.2 and 0.3).
- Step 0.5: rewrites the OpenAPI spec (``console_spec.py``) and
  schemas (``console_schemas.py``, ``server_schemas.py``) to
  document the new paths. ``test_openapi.py`` parity assertions
  swap with them.
- Step 0.6: mechanical URL sweep across the frontend
  (``console/static/app.js`` — 9 sites; ``coordinator/coordinator.js``
  — 16 sites; ``index.html`` — 1 comment).
- Step 0.7: same sweep across the test suite
  (``test_coordinator_endpoints.py``, ``test_coordinator_end_to_end.py``,
  ``test_coordinator_governance.py``, ``test_coordinator_close_all_children.py``,
  ``test_coordinator_client.py``, ``test_phase6_endpoints.py``).

Also touched:

- Server-side ``CoordinatorClient`` (``coordinator_client.py``) —
  the coord agent's HTTP path for ``close_all_children`` updates
  to the new shape.
- Handler docstrings in ``console/server.py`` say
  ``POST /v1/api/workstreams/...`` not ``/coordinator/...`` so a
  ``grep`` for a verb's URL lands on the right line.
- ``settings_registry.py`` setting descriptions, ``server.py``
  cross-process error message, migration 042 docstring — all
  updated to the unified shape.

The handler functions stay named ``coordinator_*`` until the
body-convergence follow-on lifts them into ``session_routes`` with
kind branching behind ``SessionRouteConfig`` flags. URL surface is
the only thing that changes here.

The ``test_session_routes`` route-walk now asserts the legacy paths
are GONE — previously it asserted both shapes coexisted. A future
accidental remount of ``/api/coordinator/`` would fail that test.
2026-04-24 16:56:06 -07:00
Patrick Buckley 377bd58b67 refactor(server): mount coord-only verbs through register_coord_verbs
Stage 2 Priority 0 Step 0.3 — adds ``CoordVerbHandlers`` +
``register_coord_verbs`` to ``turnstone.core.session_routes`` and
wires the seven coord-only verbs (``children`` / ``tasks`` /
``metrics`` / ``trust`` / ``restrict`` / ``stop_cascade`` /
``close_all_children``) through it on the console.

These verbs are legitimately kind-specific — they read or mutate
state (children registry, parent quota, trust / restrict policy,
cascade controls) that doesn't exist on interactive workstreams —
so they live on a Protocol distinct from
``SessionRouteHandlers``. The unified URL prefix
``/api/workstreams/{ws_id}/`` is shared with the session verbs;
the separate registrar call keeps the kind separation explicit at
the wiring site.

Legacy ``/api/coordinator/{ws_id}/{verb}`` paths stay live during
the transition; both URL shapes resolve to the same handler
function. Step 0.4 deletes the legacy shape.

The route-table walk in ``test_session_routes`` now covers all
eighteen verb pairs (eleven session + seven coord-only) so a
future drift between legacy and unified handlers fails CI.
2026-04-24 16:56:06 -07:00
Patrick Buckley e1ee84af42 refactor(server): mount coord verbs through shared session route registrar
Stage 2 Priority 0 Step 0.2 — extends ``register_session_routes`` to
cover the per-``{ws_id}`` interaction verbs (``send`` / ``approve`` /
``plan`` / ``cancel`` / ``close`` / ``events`` / ``history`` /
``detail``) and wires ``console/server.py`` to mount coord at the
unified ``/v1/api/workstreams/`` shape.

The legacy ``/v1/api/coordinator/`` paths stay live during the
transition; both URL shapes resolve to the same handler functions.
Step 0.4 deletes the legacy shape outright once the frontend
(Step 0.6) and tests (Step 0.7) move off it.

Handler bodies still live in their server modules — body
convergence (kind branching behind ``SessionRouteConfig`` flags) is
the next Step 0.2 follow-on. Splitting "URL surface unified" from
"handler bodies converged" keeps the soak-able diffs small.

``mgr`` and ``adapter`` registrar arguments are now Optional because
the console builds its coord ``SessionManager`` inside the lifespan
(after app construction). They become required again in the
body-convergence follow-on once the lifted handlers read them.

New ``tests/test_session_routes.py`` covers the registrar's mounting
rules (route ordering, attachment-quartet enforcement, legacy-close
gate) and asserts that each unified path on the console points at
the SAME handler object as its legacy counterpart — the transition
is a pure URL alias, not a fork.
2026-04-24 16:56:06 -07:00
Patrick Buckley 2b5e6cb252 refactor(server): scaffold shared session route registrar
Stage 2 Priority 0 Step 0.1 — introduces
``turnstone/core/session_routes.py`` (``SessionRouteConfig`` +
``SessionRouteHandlers`` + ``register_session_routes``) and rewires
``server.py``'s ``/v1/api/workstreams/*`` route table to mount through
it. Pure scaffolding: handler bodies stay where they are, URL surface
is byte-identical, tests pass unchanged.

Sets up Step 0.2 to lift handler bodies into the registrar and have
the console mount the same shape against its coord manager.

Adds ``SessionManager.adapter`` accessor so the registrar can pick
up the kind adapter without callers re-threading it through every
construction layer.
2026-04-24 16:56:06 -07:00
Patrick Buckley c837e3fa6d feat(core): Stage 1 SessionManager unification (#408)
* feat(core): scaffold SessionManager + SessionKindAdapter Protocol

Stage 1 step 1 — pure addition, no production wiring. Defines the
shape later steps will port the shared mechanics onto: slot
accounting, per-ws-id refcounted rehydrate locks, kind-agnostic
lifecycle; kind-specific event transport + session construction on
the adapter.

Pruned from the earlier Protocol draft (see design brief): per-kind
permission_scope (static handler map is simpler), allows_child_spawn /
quota_policy (deleted in #403), on_child_spawned (coordinator tool
owns children registry), allows_active_focus / active_id / switch
(frontend owns the active-tab state).

* feat(core): port shared session-lifecycle mechanics onto SessionManager

Stage 1 step 2. Adds create / open / close / set_state / close_idle /
get / list_all / count on top of the Step 1 scaffolding. Pure
addition — still no production wiring; the new class doesn't replace
any call sites yet.

Concurrency shape is ported from CoordinatorManager (the more-
complete side): single-phase slot reservation under the manager
lock, per-ws refcounted open-lock to serialize concurrent lazy
rehydrate, placeholder workstreams count toward max_active but can't
evict each other. WSM's two-phase eviction outside the lock is not
carried over; it had a window where a burst of creates could silently
exceed max_active.

Deletions (vs. the union of the two old managers):
- "refuse to close last workstream" guard — handled by the
  dashboard; only existed to protect the now-deleted default startup
  workstream.
- active_id / switch / get_active — frontend owns focus; server-side
  duplicate state is gone.
- _active_coords presence cache — defer measurement to Step 4; if it
  pays for itself at realistic cluster sizes, the CoordinatorAdapter
  can maintain it by observing emit_* calls.
- Children registry + reverse index — coordinator tool owns this,
  manager stays kind-agnostic.

Skill resolution (name → template_id + applied_version) is now
shared via SessionManager._resolve_skill, so WSM's pre-resolve-at-
callsite pattern and CM's internal-lookup pattern converge. Callers
pass the skill name; the manager does the lookup once.

26 smoke tests cover create eviction + overflow, concurrent-create
cap, persist/session rollback, open for missing/deleted/wrong-
kind/wrong-user rows, concurrent-open serialization, close unblocks
UI + emits closed, set_state + storage + adapter observer,
close_idle, list_all ordering, count, eviction fires adapter
transport, node_id passthrough.

* feat(core): add InteractiveAdapter for SessionManager

Stage 1 step 3. Adapter that bridges SessionManager to the node's
interactive transport:

- emit_created/state/closed → pushes onto the process-wide SSE
  global_queue (same shape current server.py handlers produce inline)
- cleanup_ui → ports WorkstreamManager._cleanup_ui body: unblock
  _approval_event / _plan_event / _fg_event, broadcast ws_closed to
  per-UI listener queues (with full-queue fallback), cancel + close
  the session
- build_ui/build_session → delegate to injected factories
  (ui_factory builds WebUI, session_factory is the existing closure
  from server.py with judge_model + memory_config captures)

Also extends SessionKindAdapter.build_session with **extra passthrough
so interactive callers can pass judge_model per-call without polluting
the manager API; and adds a reason= kwarg to emit_closed so the
frontend's "evicted" special-case keeps working (frontend doesn't
differentiate "idle" from "closed", so close_idle collapses into
close()).

14 new adapter tests cover wire payload shape, queue.Full tolerance,
cleanup_ui event unblocking + listener broadcast + queue-full
fallback, session cancel+close, graceful handling of stub UIs / None
session, and kwarg passthrough to the session factory.

* feat(console): add CoordinatorAdapter for SessionManager

Stage 1 step 4. Coordinator-side SessionKindAdapter implementation:

- emit_created/state/closed → delegate to the existing
  ClusterCollector.emit_console_ws_* methods (same wire shape the old
  CoordinatorManager emitted inline)
- cleanup_ui → ports the listener-queue + approval/plan event
  unblocks from CoordinatorManager._cleanup, with queue-full
  fallback so an unresponsive browser tab can't wedge close
- build_ui/build_session → delegate to injected factories; session
  factory doesn't accept client_type so we strip it at the adapter
  boundary

Collector emission exceptions are swallowed (same policy as today's
inline fan-out — dashboard lag on one tick is preferable to breaking
the lifecycle path).

Intentionally out of scope: the children registry (_children /
_child_to_coord) stays in the coordinator tool when wired in Step 5;
the _active_coords lock-free presence cache is deferred pending a
measurement at realistic cluster sizes. 10 new tests cover transport
payloads, collector-exception tolerance, cleanup_ui event unblock +
listener broadcast + queue-full eviction, construction passthrough.

* feat(server): wire interactive server.py to SessionManager

Stage 1 step 5a. Production-path swap: WorkstreamManager →
SessionManager(InteractiveAdapter(...)).

- Construction at server startup: build the adapter with the
  process-wide global_queue, a WebUI ui_factory closure, and the
  existing session_factory. SessionManager gets storage + max_active.
- Default startup workstream wiring removed (the CLI-REPL leftover
  flagged in the handoff's "Convergence is also a pruning
  opportunity" section). --resume now lazily creates a workstream
  scoped to the resumed content; no workstream at all if --resume
  isn't given. The dashboard handles the 0-ws state.
- HTTP handler mgr.create() calls switched to the new kw-only
  signature (user_id, name, model, skill, ws_id, client_type,
  judge_model, parent_ws_id). ui_factory/skill_id/skill_version/kind
  no longer threaded through — adapter handles UI construction and
  manager resolves skill internally.
- Dropped the mgr.last_evicted block in the /new handler (adapter
  emits ws_closed:evicted automatically on capacity eviction).
- mgr.max_workstreams → mgr.max_active.
- Added active_id / switch / switch_by_index / get_active / index_of
  / eviction_count to SessionManager because turnstone/cli.py uses
  them extensively; the handoff's "delete unless there's a live
  caller" rule flips here — CLI is a live caller.

Test fixtures across 9 files updated to build SessionManager +
InteractiveAdapter rather than WorkstreamManager. test_workstream.py
stays unchanged (it tests WSM directly; it'll be deleted in step 5d
alongside the class itself).

Full pytest: 4528 passed. Ruff + mypy clean. Next: 5b (console-side
wiring, with the children-registry relocation to the coordinator
tool).

* feat(console): wire console server to SessionManager

Stage 1 step 5b. Production-path swap: CoordinatorManager →
SessionManager(CoordinatorAdapter(...)).

- CoordinatorAdapter now owns the coord-specific bits that were bolted
  onto the old CoordinatorManager: the children registry (forward +
  reverse index), the lock-free active-coords presence cache, the
  cluster-event fan-out thread, and the worker-dispatch path
  (send / _spawn_worker). The shared SessionManager stays kind-agnostic.
- Added CoordinatorAdapter.attach(mgr) for late-binding the owning
  manager (the manager's ctor takes the adapter, so the dependency has
  to break here). Used inside _rebuild_children_registry for the tenant-
  filtered SQL query, inside send/dispatch for mgr.get(ws_id), and
  inside the fan-out seed path for mgr.list_all().
- emit_created now seeds the children registry + active-coords slot AND
  calls _rebuild_children_registry (covers both create — empty query —
  and open/rehydrate, where the subtree is persisted). emit_closed
  drops both entries. Collapses the three old call-sites in
  CoordinatorManager's create/open/close into one per-event hook.
- Console server.py builds the manager via:
      coord_adapter = CoordinatorAdapter(collector=..., ...)
      coord_mgr = SessionManager(coord_adapter, storage=..., max_active=...,
                                 node_id=ClusterCollector.CONSOLE_PSEUDO_NODE_ID)
      coord_adapter.attach(coord_mgr)
      ConsoleCoordinatorUI._coord_mgr = coord_mgr
      app.state.coord_adapter = coord_adapter
- HTTP handler call-site updates:
  - coord_mgr.create drops initial_message; the handler now calls
    coord_adapter.send(ws.id, initial_message) after create so the
    worker spawn stays out of the shared manager.
  - coord_mgr.open_admin(ws_id) → coord_mgr.open(ws_id, user_id="",
    admin=True). Matches SessionManager.open's unified signature.
  - coord_mgr.list_for_user(uid) inlined as a list comp on list_all()
    (SessionManager doesn't expose the filter; two callers).
  - coord_mgr.children_snapshot / send → coord_adapter.*.
  - coord_mgr.cancel stays (now lives on SessionManager from 5a).
- ConsoleCoordinatorUI.on_state_change now flows state transitions
  through ConsoleCoordinatorUI._coord_mgr.set_state, mirroring the
  WebUI pattern. The old _on_state_observer / _on_rename_observer
  closures the manager used to install are dead code now; leaving the
  fields in place for 5d cleanup.
- Lifespan shutdown calls coord_adapter.shutdown() (was coord_mgr.
  shutdown()) and resets ConsoleCoordinatorUI._coord_mgr on teardown.

Test fixture updates in _coord_test_helpers, test_coordinator_end_to_end,
test_coordinator_endpoints, test_phase6_endpoints: build SessionManager
+ CoordinatorAdapter in _build_mgr, set app.state.coord_adapter, switch
mgr.register_children / mgr.children_snapshot tests to mgr._adapter.*,
and rewrite test_open_admin_uses_open_admin to assert the unified
open(user_id="", admin=True) call shape.

Full pytest: 4486 passed. Ruff + mypy clean. Next: 5d (remove
CoordinatorManager + WorkstreamManager class bodies and their test
files).

* feat(core): delete WorkstreamManager + CoordinatorManager classes

Stage 1 step 5c + 5d. Final step of the unification — the legacy
classes and their test files go away now that every production
caller has been ported.

- Delete turnstone/console/coordinator.py entirely (CoordinatorManager
  class + the _enqueue_on_ui helper, which CoordinatorAdapter now hosts
  its own copy of).
- Trim turnstone/core/workstream.py to just the Workstream dataclass +
  WorkstreamKind + WorkstreamState. ~385 lines of WorkstreamManager
  logic gone; the remaining shape is pure data types shared by both
  managers.
- Delete tests/test_workstream.py (WSM-specific) and
  tests/test_coordinator_manager.py (CM-specific).
- Wire turnstone/cli.py to SessionManager + InteractiveAdapter, same
  pattern as turnstone/server.py. The CLI's WorkstreamTerminalUI uses
  manager.set_state + manager.active_id — both preserved on
  SessionManager (CLI is a live caller that keeps the focus API
  honest, per the handoff's "delete unless it pulls its weight" rule).
- Add an optional manager-level ``_on_state_change`` observer hook
  restored for the CLI's background-attention notification (the web
  path uses the adapter's emit_state; this hook covers callers that
  don't consume SSE).
- Drop dead ``_on_state_observer`` / ``_on_rename_observer`` fields
  from ConsoleCoordinatorUI — the old CoordinatorManager installed
  them; SessionManager/CoordinatorAdapter handle fan-out directly.

Vulture @ 80% confidence: zero unused symbols across the new
SessionManager + adapter files. Ruff + mypy clean (170 files).
Full pytest (excluding tests/live): 4414 passed.

Net across the whole Stage 1 branch: one unified SessionManager +
adapter Protocol replaces two ~500-line parallel managers + a
~600-line CoordinatorManager, and the interactive + coordinator
transports stay cleanly separated at the adapter boundary.

* refactor(auth): drop workstream row-level ownership gates

Turnstone is a trusted-team tool (per #400). user_id stays as
metadata for audit + display; it no longer rejects requests. Scope-
level auth via admin.workstreams / admin.coordinator tokens is the
only gate now.

Solves sec-1 (cross-tenant delete via collision on caller-supplied
ws_id, because the gate was half-implemented) and sec-2 (blank-sub
JWT bypass on empty-owner rows). Net: 359 lines of defensive
empty-string comparisons and admin=True bypass plumbing deleted.

* fix(core): serialize set_state vs close + worker spawn

Three concurrency fixes from the multi-stage review:

- bug-3: set_state now looks up ws under self._lock and gates its
  storage write on ws._closed (a new tombstone flag). close() sets
  ws._closed=True and does its storage write under ws._lock. A
  set_state that acquires ws._lock after close sees the tombstone
  and skips its write instead of resurrecting the closed row.

- bug-1: _spawn_worker wraps the check-and-spawn in ws._lock so two
  concurrent send() HTTP requests can't both observe "no live worker"
  and start duplicate worker threads on the same ChatSession.

- bug-2: replaces Thread.is_alive() as the reuse gate with an
  explicit ws._worker_running flag. The flag is set before the worker
  thread starts and cleared in its finally block — both under
  ws._lock. Using is_alive() left a narrow window where the worker
  could exit between the check and a queue_message call, stranding
  the user's message with no consumer.

perf-2 (lock-held-across-DB-write) is accepted as-is: per-ws
serialization of state transitions behind a DB round-trip is real
cost but bounded — a given ws's state flips happen sequentially on
its worker thread anyway. Dropping ws._lock around the DB write
would reintroduce the bug-3 race.

Full pytest: 4401 passed. Ruff + mypy clean.

* refactor(core): drop _resolve_skill from SessionManager

Skill resolution (name → template_id + applied_version) moves out of
the shared manager and back to the HTTP handlers that own the
create request. The interactive handler already resolved skill_data
+ applied_skill_version for other purposes (model override, judge
config, post-create session seed) and was passing the name to
SessionManager which then redundantly re-resolved via
get_skill_by_name + count_skill_versions — two wasted DB round-trips
per create on a user-visible latency path.

- SessionManager.create: accepts skill_id + skill_version as
  already-resolved kwargs; _resolve_skill helper deleted.
- turnstone/server.py create_workstream: passes the skill_id /
  applied_skill_version it already computed.
- turnstone/console/server.py coordinator_create: pre-resolves
  inline (parity with interactive) before calling coord_mgr.create.

Fixes perf-1 (redundant skill queries per create), q-4 (divergent
skill-version computation between manager and handler), q-5
(coordinator-specific lookup on the shared manager surface).

Full pytest: 4401 passed. Ruff + mypy clean.

* refactor(adapters): extract shared cleanup_ui + drop dead child-registry methods

Both InteractiveAdapter.cleanup_ui and CoordinatorAdapter.cleanup_ui
(plus their _broadcast_ws_closed_to_listeners helpers) were byte-identical.
Pull them into turnstone/core/adapters/_ui_cleanup.py:cleanup_session_ui
so the two adapters delegate to one implementation.

Also drop CoordinatorAdapter.register_children (only test callers — now
use _seed_children in tests/_coord_test_helpers.py) and _add_child
(zero callers anywhere).

* refactor(adapters): symmetric attach() + fail-loud on unattached manager

Add InteractiveAdapter.attach(manager) + .manager property mirroring
the coord-side pattern. CLI (cli.py) now uses cli_adapter.attach(manager)
instead of the _mgr_ref list-ref late-binding hack; server.py picks up
the same call for consistency.

CoordinatorAdapter.send / _rebuild_children_registry /
_prime_children_from_snapshot no longer silently return when
self._manager is None — raise RuntimeError so a forgotten attach() at
startup fails loud instead of dropping the whole fan-out.

* docs: replace stale WorkstreamManager / CoordinatorManager references

Both classes were deleted in 965e0b6; prose docstrings across the
codebase still named them. Update to SessionManager (or describe the
collapsed-into-one-class architecture where the distinction matters).

Leaves the 'Ported from …' historical markers in session_manager.py /
coordinator_adapter.py / interactive_adapter.py intact — those are
deliberate pointers back to the pre-unification code.

* fix(core): atomic close_if_idle + batch pop under one lock

bug-5: SessionManager.close_idle re-checked ws.state == IDLE outside
the lock, so a pending tool result could flip state IDLE→RUNNING
between the snapshot and close() acquiring self._lock. Add
_close_if_idle_locked that tests state + pops under self._lock.

perf-5: drop the per-victim self._lock acquisition; collect + pop the
whole batch in one acquisition, then run cleanup_ui / storage write /
emit_closed outside the lock.

* perf(coord): split emit_created / emit_rehydrated to skip storage query on fresh creates

CoordinatorAdapter.emit_created was unconditionally calling
_rebuild_children_registry (storage.list_workstreams with
parent_ws_id=... limit=10001) on every create, even for fresh-create
paths that provably have zero children.

Add emit_rehydrated to the SessionKindAdapter Protocol. SessionManager
.create still calls emit_created; .open (lazy rehydrate) now calls
emit_rehydrated. CoordinatorAdapter.emit_created seeds the registry +
fan-out but skips the rebuild; emit_rehydrated seeds + rebuilds + fans
out. InteractiveAdapter.emit_rehydrated delegates to emit_created (no
children-registry on the interactive transport).

* perf(coord): fold _active_coords into _children_lock + mutate payload in place

perf-4: _active_coords used a copy-on-write dict-swap pattern so the
fan-out dispatch could read it lock-free, but _dispatch_child_event
already re-validates the parent under _children_lock anyway — the
lock-free snapshot was premature. Replace with a plain dict read+write
both under _children_lock; install and remove collapse to one-liners.
Value also drops the user_id half — dead after a46dab1 removed
row-level ownership gates — so _active_coords is now just
coord_ws_id → ui.

perf-6: _enqueue_on_ui was doing {**payload, "ws_id": coord_ws_id} on
every dispatch. The dispatch path owns payload and doesn't reuse it —
mutate in place.

* test(coord): add adapter tests for worker dispatch + children registry + fan-out

Fills the coverage gap on CoordinatorAdapter — the review (q-3) flagged the
coord-specific concurrency paths ported from the deleted CoordinatorManager
as untested. Three new test classes:

- TestCoordinatorAdapterWorkerDispatch: _spawn_worker reuse gate, queue.Full
  backpressure, concurrent-call bug-1 reproducer (two threads → exactly one
  worker via ws._lock + _worker_running), finally-clears-flag.
- TestCoordinatorAdapterChildrenRegistry: registry seed on emit_created vs
  emit_rehydrated rebuild, _pop_coord_registry_locked reverse-index cleanup,
  _merge_child_ids_locked idempotency, _prime_children_from_snapshot merge.
- TestCoordinatorAdapterDispatchChildEvent: unknown-parent drop, ws_created
  fan-out, cluster_state / ws_closed reverse-index routing, perf-6 in-place
  ws_id stamp.

* fix: regressions flagged by ultrareview

Verify stage of the cloud review surfaced 6 confirmed regressions
from Stage 1's adapter layer. Fixing together since they share the
same root cause (plumbing moved into adapters without retiring the
old emission paths).

- Interactive adapter emit_created / emit_state / emit_rehydrated
  become no-ops. The create_workstream HTTP handler still fires
  ws_created (after attachment validation, per the pre-Stage-1
  "no phantom events on rejected upload" contract); WebUI
  _broadcast_state still fires ws_state with the full payload
  (tokens + context_ratio + activity). Firing from the adapter too
  was duplicating both events. Also closes the phantom-ws-created
  regression (adapter fired before attachment validation ran).

- emit_closed Protocol gains a ``name`` kwarg; the adapter is the
  sole emitter for ws_closed on interactive now, and the frontend
  eviction toast needs the name. Manager passes ws.name from
  close() / create()+open() eviction / close_idle paths.

- _idle_cleanup_thread stops firing its own reason="idle" ws_closed
  — close_idle already fires via the adapter with reason="closed",
  and the frontend never differentiated the two anyway.

- close_workstream_endpoint fix: "Cannot close last workstream" 400
  was a stale error (the guard went away with the default-startup
  workstream). Return 404 on close() == False (which now means the
  ws was already closed or unknown). Also switches the audit actor
  from _require_ws_access's stored owner to _auth_user_id — the
  stored owner is metadata post-#400, so attributing actions to it
  misrepresents who actually did them.

- CLI /ws close mirrors the same stale-error fix.

- SessionManager.close now calls storage.delete_workstream_override
  alongside update_workstream_state, same as the old
  WorkstreamManager.close did. Without it overrides leak until
  tombstone cleanup. close_idle does the same.

- SessionManager._reserve_and_install_locked records the eviction
  on turnstone.core.metrics so the global eviction counter keeps
  working. Old WSM did this inline; the unification dropped it.

- ConsoleCoordinatorUI.on_rename now fans out to the cluster
  collector via a new class attribute ``_collector`` (set at
  console startup alongside ``_coord_mgr``). The old
  ``_on_rename_observer`` plumbing went away with
  CoordinatorManager and the "adapter emit_console_ws_rename runs
  from whichever code path renames" comment was aspirational —
  nothing actually did it.

Full pytest: 4375 passed (tests/live + test_server_live.py excluded;
both pre-existing live-backend failures unrelated to this branch).
Ruff + mypy clean.

* refactor(ui): extract SessionUIBase for shared UI scaffolding

Direct response to review feedback that the unification wasn't
merging enough of the two workstream kinds. WebUI (node) and
ConsoleCoordinatorUI (console) both:

- Keep a per-UI list of SSE listener queues guarded by a lock
- Block a worker thread on _approval_event / _plan_event
- Fan enqueued events out with the same ws_id-stamping pattern
- Resolve approvals / plans with the same broadcast-then-signal
  pattern

All of that now lives once in turnstone/core/session_ui_base.py.
Both UIs subclass SessionUIBase; kind-specific bodies (WebUI's
per-UI metrics + _broadcast_state + intent-verdict bookkeeping,
ConsoleCoordinatorUI's collector fan-out) stay in the subclasses.

WebUI.resolve_approval still overrides the base (it adds intent-
verdict updates) but now calls super() for the shared broadcast +
event-set steps. Same shape as the other approval/plan hooks:
subclasses extend, base provides skeleton.

Net file-level: +156 LOC for the base, -144 LOC across the two
subclasses. The raw number is unexciting — but there's now a
single source of truth for the listener + blocking-gate machinery,
and bugs (like the duplicate ws_created / ws_state events that
prompted this refactor) can't arise from the two implementations
drifting.

Full pytest: 4375 passed. Ruff + mypy clean.

* refactor(ui): move metrics + verdict bookkeeping into SessionUIBase

Second pass at unifying the two UIs. Per-workstream metrics
accumulators (token counts, tool-call counts, context ratio,
activity tracking), intent-judge verdict cache + pending-decision
list, and the verdict-persistence path all move to SessionUIBase.

Before: WebUI tracked all of it; ConsoleCoordinatorUI tracked none
of it (a comment on the old on_intent_verdict literally admitted
the deferral — "skip the persistence + late-decision plumbing that
WebUI does"). Coord sessions never got verdict rows in storage, never
had a user_decision stamped, and the dashboard had no way to show
coord token usage because the data wasn't captured.

Now the base class captures the data and persists the rows for
every kind. Kind-specific broadcast (WebUI's _broadcast_state with
rich per-UI payloads) stays on WebUI; prometheus counters on the
node (_metrics.record_judge_verdict) stay on WebUI's on_intent_verdict
override. Everything else shared.

Behaviour change worth flagging: coord sessions now write
intent_verdicts and output_assessments rows for every judge call
and every output-guard warning. Previously silent; the storage rows
now exist and any future coord-dashboard surface can read them.

Shape of the unification:
- resolve_approval: was overridden on WebUI (intent-verdict decision
  propagation); now lives on the base. Both kinds inherit unchanged.
- on_intent_verdict: WebUI overrides only to add _metrics.record_*;
  rest of the body is the base.
- on_output_warning: was on both separately; fully base-shared now.

Full pytest: 4375 passed. Ruff + mypy clean.

* fix: regressions flagged by second-pass review

Three confirmed findings with direct fixes + a dedicated test file
for SessionUIBase (was previously uncovered).

bug-1 — Coord approve_tools didn't reset _last_verdict_decision or
clear _llm_verdicts between approval rounds. WebUI did (inline).
Coord inherited SessionUIBase.on_intent_verdict which stamps via
the decision flag, so after the first resolve every subsequent
round's verdicts were stamped with the prior round's user_decision
before the user had decided the new round.

Fix: add SessionUIBase._reset_approval_cycle() clearing both under
_ws_lock; call from the top of both subclass approve_tools methods.
Single-source invariant — can't drift again.

sec-1, sec-2 — delete_workstream_endpoint and open_workstream's
rehydrate path recorded the audit row under the stored ws.user_id
("owner_uid") rather than the authenticated caller. With row-level
ownership gating gone (a46dab1), any team member acting on a peer's
workstream produced an audit row naming the victim as the actor.
Fix: pass _auth_user_id(request) as the audit actor, matching the
pattern close_workstream already follows.

q-2 — SessionUIBase had no direct tests. The new
tests/test_session_ui_base.py covers listener fan-out, approval +
plan blocking gates, intent-verdict cache + FIFO eviction, verdict
persistence paths, output-guard persistence, the reset-between-rounds
invariant (bug-1 regression test), a cross-subclass test that
verifies BOTH WebUI.approve_tools and ConsoleCoordinatorUI.approve_tools
call _reset_approval_cycle (verified it fails without the fix), and
a concurrent enqueue/register smoke.

Full pytest: 4395 passed (+20 new). Ruff + mypy clean.

* fix: PR #408 review findings from copilot + code-quality

Three substantive fixes + mechanical side-effect-in-assert cleanup.

Copilot findings:

- session_ui_base.py: on_intent_verdict had a race with
  resolve_approval. Previously acquired _ws_lock twice (read decision
  → release → if unset, acquire again to append). resolve_approval
  could interleave between the two acquisitions, swap-and-clear the
  pending list and set the decision — our verdict then got appended
  to the fresh (empty) list and stamped with the NEXT round's
  decision on the following resolve. Fix: decision-check + append
  under ONE acquisition; storage UPDATE (if decision already set)
  runs outside the lock. New regression test counts lock
  acquisitions during on_intent_verdict and fails if the two-phase
  pattern returns.

- server.py close_workstream_endpoint: comment said "treat as
  already-closed success" but handler returned 404. Comment
  rewritten to match the 404 behaviour ("the ws isn't tracked here"
  is the only reachable meaning for close() → False now).

- test_session_ui_base.py concurrency smoke: the test ended with
  ``pytest.assume = lambda ...`` — a leftover that mutates pytest
  globals and can surprise other tests. Replaced with explicit
  ``not is_alive()`` assertions so the "threads completed cleanly"
  intent survives -O optimization stripping.

Code-quality (assert side-effects):

Six ``assert mgr.open(...)`` / ``assert mgr.close(...)`` in
test_session_manager.py stripped under ``python -O``. Mechanical
fix: extract to local before asserting.

Ignored the two "Protocol method body is `...`" flags — that's the
standard Protocol idiom; replacing with ``pass`` or
``NotImplementedError`` changes typing semantics.

Full pytest: 4396 passed.
2026-04-24 14:28:51 -07:00
renovate[bot] e7fd9e53b8 chore(deps): lock file maintenance (#407)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-23 21:59:35 -07:00
renovate[bot] 47cd1dbfeb chore(deps): update astral-sh/setup-uv action to v8 (#406)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-23 21:15:51 -07:00
renovate[bot] bf36461187 chore(deps): update helm release postgresql to ~18.6.0 (#405)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-23 21:15:38 -07:00
renovate[bot] 2ef4243024 chore(deps): update dependency vitest to v4.1.5 (#404)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-23 21:15:26 -07:00
Patrick Buckley 58d20f4012 chore(coord): remove spawn-quota subsystem (#403)
* chore(coord): remove spawn-quota subsystem

The quota gate was operator-level safety per its own comments, not a
security boundary, and never fired in a week of heavy use. Runaway
coordinator spawns are already bounded by max_active slot exhaustion,
which surfaces to the coord LLM as a tool error — same operational
shape, one fewer moving part. Precedes the Stage 1 SessionManager
unification so the coord tool doesn't inherit quota bookkeeping.

Upgraded deployments with the three removed settings persisted will
log three "Skipping invalid setting" warnings on startup and
otherwise degrade cleanly; a follow-up migration to delete the rows
would silence that noise.

* chore(migrations): drop stale coord spawn-quota settings rows (047)

Clears persisted rows for the three ConfigStore keys removed in the
previous commit so upgraded deployments don't log "Skipping invalid
setting" warnings on every startup. Downgrade is a no-op — the rows
were operator-set values, and a rollback to pre-1.5.0 code falls back
to the registry defaults for any key not present.
2026-04-23 20:09:12 -07:00
Patrick Buckley f5ec9cd2b7 fix(coord): render markdown on history reload (#402)
* fix(coord): render markdown on history reload

The coordinator chat's history-load path piped assistant content
through ``appendText`` → ``appendMsg(role, esc(text))``, which dumps
escaped raw text into the message body without ever calling the
markdown converter or the post-render hooks (highlight.js, mermaid,
KaTeX).  Live streaming uses ``streamingRender`` /
``streamingRenderFinalize`` which DO render markdown, so a fresh
stream looked correct but a page-reload / reconnect surfaced every
table, code fence, and math block as literal characters.

Now the history loop dispatches by role: assistant + reasoning go
through ``streamingRenderFinalize`` (mirrors what live streaming does
on stream_end); tool messages keep ``appendToolResult``; user / system
stay on ``appendText`` since they're typed verbatim and don't carry
markdown structure.

* fix(coord): keep reasoning role on plain-text path on history replay

The history loop routed reasoning role through streamingRenderFinalize,
but live streaming renders reasoning tokens via textContent
(appendReasoningToken).  History replay would render reasoning as
markdown while a fresh stream rendered it as plain text — inconsistent
look and unnecessary hljs / mermaid / KaTeX work on reasoning content.

Reasoning now uses appendText on replay, matching the live path.

Addresses Copilot review feedback on PR #402.
2026-04-23 19:18:09 -07:00
Patrick Buckley d8f2e43edb chore: bump version to 1.5.0a4 2026-04-23 18:46:15 -07:00
Patrick Buckley 4fe6e8678e fix(server): trusted-team workstream visibility on listing endpoints (#400)
* fix(server): trusted-team workstream visibility on listing endpoints

The per-user filter on /v1/api/workstreams, /v1/api/dashboard, and
/v1/api/workstreams/saved (PR #375's _visible_workstreams helper) was
written for a multi-tenant SaaS threat model that doesn't match how
turnstone gets deployed.  In a self-hosted, trusted-team install the
filter created friction without preventing the relevant threats — and
hid the auto-created name="default" startup workstream from every
web user, leaving fresh installs staring at a blank dashboard.

Listing endpoints now return the cluster-wide set to any authenticated
caller.  Per-workstream MUTATIONS (/send, /close, /open, /title,
/delete, /refresh-title) keep their independent ownership checks — the
cross-tenant guards from PR #375 stay in force on those handlers (see
TestCrossTenant{Delete,Approve,Close,Title,Open}).  Listing only
exposes metadata (name, state, kind, message_count); message history
still requires the per-workstream gate on /history.

Resuming a saved workstream still goes through /open's owner check, so
the metadata-leak surface ends at "you can see workstream X exists" —
not at any actionable cross-user capability.

The console collector's service-scope is now load-bearing only for the
SSE event stream gate (/v1/api/events/global); kept anyway as belt-
and-braces.

If turnstone is ever deployed as a true multi-tenant SaaS, the right
boundary is a real ``tenant_id`` column with row-level filtering at
the storage layer, not the empty-user_id heuristic this used to apply.

Tests updated to assert the new contract: listing returns all owners;
mutation gates unchanged.

* fix(server): repair test mocks + tighten docstrings on listing endpoints

- tests/test_auth.py: TestServerAuth + TestServerLogin mocks now set
  kind / parent_ws_id / user_id explicitly so /v1/api/workstreams JSON-
  serializes them.  Bare MagicMock attributes return another MagicMock
  that fails json.dumps and surfaces as 500.

- turnstone/server.py: list_saved_workstreams docstring corrected to
  describe what the endpoint actually returns (summary metadata, not
  history) and to spell out that ownerless persisted rows are claimable
  by any authenticated caller via /open — consistent with the trusted-
  team model the listing endpoints assume.  Same callout added next
  to the open_workstream ownership-gate block.  Comments throughout
  rewritten to be timeless (no "previously" / PR-number references).

- tests/test_server_authz.py: TestSaved... docstring matches the actual
  /open behavior for orphan rows (claimable by any authenticated
  caller, not a separate admin path).
2026-04-23 18:44:46 -07:00
Patrick Buckley 96abaf32b2 fix(chat): collapse phantom whitespace + tighten paragraph rhythm (#401)
* fix(chat): collapse phantom whitespace + tighten paragraph rhythm in markdown body

The assistant chat body was rendering 30-50px gaps between every
section.  Two compounding causes:

1. ``.ts-msg-body`` had ``white-space: pre-wrap`` on the markdown
   container.  The custom regex-based markdown converter
   (renderer.js) leaves ``\n`` text nodes between block siblings —
   pre-wrap rendered every one of those as visible vertical space,
   stacking ~14-16px between every heading/paragraph/katex-display.

2. No ``.ts-msg-body p`` margin override, so paragraphs fell back to
   browser-default 1em top + 1em bottom (~28px stacked between any
   two paragraphs).  Headings already had a tight ``8px 0 4px`` rule;
   paragraphs were the outlier.

Switched the body to ``white-space: normal`` and added a
``.ts-msg-body p { margin: 6px 0 }`` rule that matches the heading /
list / blockquote rhythm.  Mirrored the paragraph rule on the design-
v1 ``.msg-body`` selector so both legacy and v1 surfaces stay in sync.

``<pre>`` blocks have ``white-space: pre`` built in so fenced code
still preserves formatting.  Mid-stream partial fences (before the
closing ``\`\`\`` arrives) render as collapsed text for one frame and
then snap back when the next render tick wraps them in ``<pre>`` —
acceptable trade vs. the persistent gap regression.

User-typed messages render through ``.msg-user-text`` (a separate
DOM path), so this only affects assistant markdown output.

* fix(chat): preserve inline <code> whitespace under white-space: normal body

The body's ``white-space: normal`` (which collapses phantom inter-block
``\n`` text nodes from the markdown converter) inherits to inline
``<code>`` and silently collapses multiple spaces inside backtick
spans.  ``<pre>`` blocks rely on the user-agent ``pre { white-space:
pre }`` rule and are unaffected; only bare inline code needs an
explicit override.

Adds ``white-space: pre-wrap`` to ``.ts-msg-body code`` (chat.css) and
the design-v1 ``.msg-body code`` selector so backtick-wrapped code
spans render verbatim while still wrapping on long lines.

Addresses Copilot review feedback on PR #401.
2026-04-23 18:44:30 -07:00
Patrick Buckley 436ce5630b feat(coord): saved coordinators surface + shared session-card primitives (#399)
* feat(coord): saved coordinators surface + shared session-card primitives

The console home view now lists explicitly-closed coordinators in a
"Saved Coordinators" card grid below the active list.  Click a card →
POST /v1/api/coordinator/{ws_id}/open then navigate; capacity issues
surface as a toast instead of a broken detail page.  Card click is
de-duped by an `is-busy` class so rapid double-clicks don't fire
parallel resurrects.

GET /v1/api/coordinator/saved is the new backend endpoint (mirrors the
interactive list_saved_workstreams shape).  Filters at the SQL layer
to state='closed' via a new optional `state` parameter on
list_workstreams_with_history (added to the protocol + both backends);
also drops any rows currently loaded into coord_mgr as defence in
depth.  The blocking storage call + the lock-acquiring list_all are
offloaded via asyncio.to_thread to match coordinator_create's pattern.

CoordinatorManager._open_impl now allows resurrect of state='closed'
rows (deleted is still a tombstone).  The DB state-flip on resurrect
that the first cut had is gone — it raced concurrent close()s and the
next set_state() call syncs the DB naturally; the saved list filters
already keep a still-loaded coordinator from appearing as a saved
card even when its on-disk state lags.

Frontend dedup that paid for the saved surface ships in the same diff:

  - shared_static/cards.css: lifted from ui/static/style.css so both
    surfaces share the basic card primitive (delete-mode rules stay
    interactive-only until coordinator gets the same UX)
  - shared_static/cards.js: new renderSessionCard(sess, opts) helper
    used by both renderSavedWorkstreams (interactive) and
    renderSavedCoordinators (console)
  - shared_static/utils.js: formatRelativeTime moved here from
    ui/static/app.js

Coordinator landing visual fixes folded in:
  - .home-section-title now uses var(--accent) so the COORDINATORS
    heading reads as a peer of the NODES heading
  - .home-panel dropped its bg/border/padding so the composer is no
    longer double-framed (matching the dashboard-composer feel)
  - "Active coordinators" → "Saved Coordinators" rename + "Coordinators"
    on the active list

ws_closed SSE handler now gates on the closed ws's kind so interactive
closes don't spam /v1/api/coordinator/saved on busy clusters.
loadSavedCoordinators in-flight de-dup coalesces close-event bursts to
one fetch instead of N.

Tests cover: caller-scoping, admin sees-all, blank-uid fail-closed,
loaded-coordinator filtering, state filter (idle rows excluded), plus
the manager-level open-resurrect / open-refuses-deleted contracts.

Closes the bug-{1,2,3}, perf-{1,2,3,4}, sec-{1,2}, q-{1,2,3,4,5,6,7}
findings from the prior multi-stage review.

* fix(design): restore amber accent on the v1 design system

The Claude Design handoff swapped the accent hue to teal (h=182).
Walking back to amber (h=75) — turnstone's original brand colour.
Lightness + chroma bumped slightly (0.62→0.7, 0.10→0.13) so the
restored gold matches the visual weight of the legacy #e5a042 token.

Hue map header comment updated to record what happened so the next
person doesn't repeat the swap.  Only surfaces with data-design="v1"
on <html> pick this up — currently just turnstone-server's webui.

* chore: gitignore design_ideas/ and .claude/ dev directories

design_ideas/ holds personal Claude Design handoff scratch + reference
HTML; .claude/ holds per-user Claude Code state (worktrees, settings,
plugin caches).  Neither belongs in version control.

* fix(coord): address PR #399 review nits

- tests/test_coordinator_endpoints.py: split `assert mgr.close(ws.id)`
  in `_seed_closed_coord_with_history` so the close call always runs
  even under `python -O` (asserts stripped).  Same fix in
  test_coordinator_manager.py's `test_open_refuses_deleted_coordinator`
  for the open() and open_admin() calls.
- shared_static/cards.css: `.card-wsid` now reads `var(--font-mono, "IBM
  Plex Mono", monospace)` so design-v1 surfaces pick up the JetBrains
  Mono token while console (still pre-v1) keeps the literal fallback.
2026-04-23 17:48:19 -07:00
Patrick Buckley f510699a4f feat(auth): inline refresh response + sessionStorage rehydrate hardening (#398)
* feat(auth): inline refresh response + sessionStorage rehydrate hardening

The proactive refresh path now consumes the /refresh response body
inline (permissions + exp), eliminating the chained /whoami round-trip
and the brief stale-sessionStorage window after refresh succeeds but
before whoami completes.

Adds AbortController + _loggedOut guards to the whoami fetch so a
logout fired mid-flight cannot re-populate sessionStorage after it
clears.  A non-OK whoami on tab restore now explicitly clears
sessionStorage instead of silently leaving stale cosmetic permissions
(server-side identity gone → UI gating reflects it on next render).

Surfaces window.permissionsReady (one-shot promise) so permission-
gated UI can await the initial whoami's completion instead of guessing
a setTimeout duration.

Tests cover the new refresh response shape, the existing leeway path,
the storage-failure fallback, and the no-perms 403 path.

Closes the bug-3 / perf-4 / sec-1 / q-6 findings from the multi-stage
review of the prior uncommitted change set.

* fix(auth): guard whoami superseding race in _scheduleRefreshFromWhoami

_scheduleRefreshFromWhoami is invoked from several entry points
(initial page load, _onSuccess, BroadcastChannel "login"/"refresh",
_tryRefresh fallback).  Two firing in quick succession could let an
older slow whoami land after a newer one and clobber its effects —
clearing permissions right after a successful login, or rescheduling
the refresh timer off stale exp.

Now aborts any prior _whoamiAbort before starting a new request and
guards the .then's _storePermissions / _scheduleRefreshAt with a
`_whoamiAbort === ctrl` check so a late arrival from a superseded
call is fully neutralised.

Addresses Copilot review feedback on PR #398.
2026-04-23 17:43:48 -07:00
Patrick Buckley fa53b414ed feat(providers): add gpt-5.5 and gpt-5.5-pro capability entries (#396)
* feat(providers): add gpt-5.5 and gpt-5.5-pro capability entries

OpenAI announced gpt-5.5 on 2026-04-23 (ChatGPT/Codex first, API
"very soon"). Mirror the gpt-5.4 / 5.4-pro capability shape: 1M
context, native tool search, vision, xhigh effort; pro is
always-reasoning with no temperature and medium/high/xhigh only.

No provider-logic changes needed — OpenAI announced no API-surface
changes vs 5.4. Cache retention already covers 5.5 via the existing
startswith("gpt-5") prefix rule.

* test(providers): cover gpt-5.4-pro and gpt-5.5-pro in cache retention test

Pro variants share the same gpt-5 prefix and should keep 24h
retention; explicit coverage guards against regressions if the
prefix rule narrows in the future.
2026-04-23 15:20:59 -07:00
Patrick Buckley e4070c2f8c chore(ci): remove trivy docker security scan (#397)
Remove the weekly Trivy scan job and the .trivyignore exclusion file.
The scanner has been flagging base-image CVEs that require no action
on our part (upstream-only fixes) and has provided no actionable
signal, while breaking CI on an ongoing basis.
2026-04-23 15:12:55 -07:00
Patrick Buckley 42d22bb6b4 feat(auth): cookie refresh endpoint, JWT leeway, coord-token observability (#395)
* feat(auth): cookie refresh endpoint, JWT leeway, coord-token observability

Three robustness wins around the auth/JWT layer.

1. POST /v1/api/auth/refresh — handle_auth_refresh in core/auth.py,
   wired in both console/server.py and server.py.  Sliding-window
   re-mint of the auth cookie.  Re-resolves the user's permissions
   from storage so a role change propagates within one refresh cycle
   instead of persisting until the original cookie's natural expiry.
   Returns the same JSON shape as /api/auth/login plus a fresh
   Set-Cookie header.  Refuses to extend a session for a deleted /
   role-stripped user (403).

   Resolves the user-visible "401 after browser tab open >24h"
   symptom: previously the only refresh path was a full re-login,
   now a single POST extends the session.

2. validate_jwt now passes leeway=30 to PyJWT.  Absorbs minor
   clock skew between hosts (multi-replica console deployments) and
   between mint-time and validate-time within the same process.
   Standard tolerance for short-lived tokens.

3. CoordinatorTokenManager._mint logs at debug.  Mirrors the pattern
   in ServiceTokenManager._mint (auth.py).  Premature-401 diagnostics
   would have been an order of magnitude faster with this in place
   the first time around.

Frontend (shared_static/auth.js):

- _scheduleRefreshFromWhoami() reads the JWT exp surfaced via /whoami
  and sets a setTimeout at 90% of remaining cookie life to call
  /refresh.  Floor 30s, ceiling 24h.  Fires on initial page load
  (silent if not authenticated) and after every successful login.
- _tryRefresh() de-dupes concurrent callers via a shared in-flight
  promise — many parallel authFetch's hitting 401 at once still only
  fire one /refresh.
- authFetch on-401 now attempts a single reactive refresh-then-retry
  before falling through to the login overlay.  Covers cases where
  the proactive timer didn't fire (tab restored from disk-cache after
  expiry, system clock jump, page first-load with stale cookie).
- BroadcastChannel "refresh" message keeps sibling tabs in sync so
  they don't redundantly hit /refresh themselves.
- logout() cancels the proactive timer.

Tests:

- validate_jwt accepts 10s-expired tokens (within 30s leeway).
- validate_jwt rejects 60s-expired tokens (past leeway).
- /whoami includes exp claim with sane bounds.
- /refresh returns ok + Set-Cookie + the refreshed cookie keeps
  working on subsequent authenticated requests.
- /refresh without a cookie returns 401.

Not addressed: the coordinator.session_jwt_ttl_seconds ceiling
(currently 1h) — that's a separate, preventative concern for very-
quiet long-running coordinators, orthogonal to the user-visible 401
this PR fixes.  Can bump in a follow-up if it actually surfaces.

* fix(auth): address Copilot PR #395 feedback

Two real bugs caught by Copilot, both fixed.

1. Storage failure was indistinguishable from "user deleted" in
   handle_auth_refresh.  _load_user_permissions() swallows exceptions
   and returns set(), so a transient DB hiccup looked like
   "user has no permissions" and returned 403 — logging the user out.

   Now calls storage.get_user_permissions() directly with try/except.
   - Exception → log + fall through to in-token claims (refresh succeeds
     with stale-but-valid permissions; better than fail-closed mid-
     session for a hiccup).
   - Empty set returned (no exception) → 403 (legitimate signal: user
     deleted or role-stripped).

   Tests:
   - test_refresh_storage_failure_falls_back: storage raises → 200 +
     in-token permissions.
   - test_refresh_user_with_no_perms_403: storage returns empty → 403.

2. Logout race: a /refresh in flight when the user clicks Logout could
   land AFTER /logout's clear-cookie response and re-set the cookie
   from /refresh's Set-Cookie header, silently undoing the logout.

   Fix in shared_static/auth.js:
   - Add a _loggedOut latch + _refreshAbort AbortController.
   - logout() sets _loggedOut = true synchronously and aborts any
     in-flight /refresh BEFORE the /logout fetch fires.
   - _tryRefresh() bails on its post-fetch effects (don't store perms,
     don't reschedule, don't broadcast) when _loggedOut is set.  The
     stale Set-Cookie from /refresh is harmless because /logout's
     response overwrites it on the way back.
   - _onSuccess() (re-login) clears the latch so subsequent refreshes
     work again.

   Race window is small but real on slow networks / contested CPU.
2026-04-22 20:36:00 -07:00
Patrick Buckley eedf700d3b chore: bump version to 1.5.0a3 2026-04-20 20:13:23 -07:00
Patrick Buckley 4d667a2cdc feat(design-system): DS phase 2 — opt server chat UI into v1 primitives (#392)
* feat(design-system): DS phase 2 — opt server chat UI into v1 primitives

ui/static/index.html:
  - data-design="v1" on <html> opts this view into design system tokens
    and primitives scoped under the attribute selector.
  - Link DS stylesheets after the legacy cascade: tokens + typography +
    appbar (chrome) + panel / buttons / pills / message / field
    (primitives). Legacy /shared/base.css, /shared/ui-base.css,
    /shared/chat.css, and /static/style.css stay linked to handle
    anything not yet migrated (rich markdown, tabs, dashboard, split
    panes, approvals, modals).
  - Header <div id="header"> picks up .appbar + .appbar-title +
    .appbar-status + .appbar-spacer + .appbar-actions alongside the
    legacy .ts-header classes. Theme-toggle gets .btn for DS pill shape
    while keeping .header-btn for palette continuity.

ui/static/app.js:
  - Chat message elements emit both legacy and DS class names so the
    DS primitive picks up the message surface while legacy .ts-msg--*
    rules keep view-specific markdown styling (tables, callouts, katex,
    mermaid, hljs). Pairs:
      ts-msg ts-msg--user       → + msg user
      ts-msg ts-msg--assistant  → + msg assistant
      ts-msg ts-msg--reasoning  → + msg reasoning
      ts-msg ts-msg--info       → + msg info
      ts-msg ts-msg--error      → + msg error
      ts-msg-body               → + msg-body
  - Approval blocks keep legacy-only styling — their shape is distinct
    from the DS .msg primitive (the DS approval-dock pattern is a
    fixed bottom dock, not inline-in-chat).

No backend or wire-format changes. SSE events, POST bodies, endpoint
URLs, ARIA attributes, and keyboard shortcuts all unchanged.

* feat(ui/static): DS-skin tool-call + approval + verdict internals

The outer .ts-msg.ts-approval--inline picked up DS .msg styling via
PR #2's dual-class approach, but the inner structure kept rendering
with legacy yellow/green/red colours and legacy chip shapes. Result:
a DS-accent-bordered card containing a mustard tool-name, a clunky
uppercase-yellow verdict chip, and a mismatched auto-approved pill.

Add [data-design="v1"]-scoped overrides that reskin the inner
vocabulary onto DS tokens:

  .ts-approval-tool          panel-over-panel-2 card with hair border
  .tool-name                 accent (teal) for tool-kind identity
  .tool-cmd / .tool-diff     ink-2 text; diff-del/add/warn → err/ok/warn
  .verdict-badge.verdict-*   chip aesthetic matching DS k-badge —
                             low=ok-tinted, medium=warn-tinted,
                             high/critical=err-tinted, with a
                             3px left-border semantic stripe
  .verdict-detail            panel-2 callout with structured rows
  .verdict-judge-spinner     ts-pulse animation (reuses primitive)
  .ts-approval-badge--*      pill shape hugging max-content, matches
                             DS approve-button-family colour palette
                             (ok-text, err-text-mix)
  .tool-output               panel bg, hair border, accent stream
                             left-border, fade-gradient on collapse
  .ts-verdict-glow--*        soft ring on the corresponding action
                             button (approve=ok, deny=err, review=warn)

No JS changes; DOM shape unchanged. CSS-only reskin so approval flow,
tool streaming, and verdict expand/collapse behaviour all stay intact.

* fix(ui/static): consistent tool-card width + flat badge aesthetic

Two fixes to the DS-skinned tool-call rendering:

1. Tool-call cards were sizing to their content (short output →
   narrow card, long output → full-width), producing a jagged column.
   Force .ts-msg.ts-approval--inline to width: 100%; align-self:
   stretch; box-sizing: border-box; so the chat column reads evenly.

2. The "approved" / "auto-approved" pill was styled as a button
   (pilled shape, 1px bg-tinted border, 4x10 padding) which read
   as clickable.  Switched to a flat badge aesthetic matching the
   .risk primitive: 3px-squared, 2x6 padding, 10px mono uppercase
   on a --ok-soft / --err-soft tinted surface, no border.  Reads
   as a status tag, not a call-to-action.

* fix(ui/static): address Copilot PR #392 feedback

Copilot findings, all applied:

- Drop the legacy Outfit + IBM Plex Mono Google Fonts link.  DS
  typography.css @imports Inter + JetBrains Mono; loading both stacks
  on opted-in pages wastes downloads and triggers FOIT/FOUT differences.

- Drop the .ts-header-title class on the <h1>.  Its legacy rule forces
  font-family: var(--font-display) (Outfit) which overrides the DS
  appbar typography.  .appbar-title alone is sufficient under v1.

- Override .ts-msg font-family under [data-design="v1"] when .msg is
  also present (and not the .tool variant).  Legacy .ts-msg forces
  mono; DS user/assistant/reasoning/info/error messages should use
  the UI font.  .msg.tool keeps mono via the primitive's own rule.

- Replace the inline name.style.color = "var(--red)" in buildToolDiv
  with a .tool-name--error class.  Inline styles win over CSS rules
  and broke the DS token mapping (legacy --red is not the DS --err).

- Correct the header comment in style.css for the approval-block
  overrides.  Prior comment claimed the outer wrapper picks up DS .msg
  styling; it doesn't — the dual-class approach wasn't extended to
  approval blocks.  Updated comment to match actual DOM.
2026-04-20 20:10:26 -07:00
Patrick Buckley 581a8c41b1 feat(design-system): DS phase 3 — coordinator chat migration (#393)
* feat(design-system): DS phase 3 — coordinator chat migration

Opt the per-session coordinator view into data-design="v1" and migrate
its rendering to the DS primitives + patterns shipped in phase 1.  This
is the larger of the two parallel chat migrations (the other being the
server UI under turnstone/ui/static/).

Scope — this PR touches two files only:

  turnstone/console/static/coordinator/index.html
    - data-design="v1" on <html>; DS stylesheets linked after the legacy
      base so primitives win on specificity and legacy styles keep
      covering anything not-yet-migrated.
    - Header rewired from .ts-header to .appbar with .appbar-back,
      .appbar-title + .dim subtitle, .appbar-spacer, .appbar-status for
      SSE state, and .appbar-actions wrapping the cancel / end / theme
      buttons (now .btn pills).
    - Approval bar replaced with the .approval-dock pattern.  Signature
      change: amber Approve becomes an ok-family (green) filled button
      with 1.5px border + --r-md squared shape.  .dcall rows frame each
      pending call like a mini inspectable code line.  Action cluster
      sits in a .drow with Deny (.act.danger) / Always (.act.always) /
      Approve (.act.primary) and the preview's kbd affordances
      (D / ⇧A / ⏎).  role="region" + aria-live="assertive" preserved;
      the dock stays non-modal (no focus trap), focus moves to the
      primary Approve button on open via the existing handler.
    - Sidebar shell adopts .sidebar + .side-section + .side-label +
      .ghost refresh buttons.  Coordinator-only .sidebar overrides unset
      the DS sticky-left-column defaults (which assume an admin-shell
      grid) so the aside continues to flex into the right column of
      #coord-body.  Tree-row + task-row styling stays view-local,
      rehomed to DS tokens (--hair-2 hover, --accent focus, --ok/--warn/
      --err + -soft task-status tints).
    - Inline <style> trimmed of rules now covered by DS primitives;
      only the coordinator-specific flex wiring, tree-row visuals, and
      <700px responsive accordion remain.

  turnstone/console/static/coordinator/coordinator.js
    - appendMsg() emits .msg + role variant (.msg.user / .msg.assistant /
      .msg.reasoning / .msg.tool / .msg.error / .msg.info) and .msg-body.
      _TS_ROLE_VARIANTS renamed _MSG_VARIANTS.
    - Streaming helpers query .msg-body; SSE dedup-by-call-id query
      updated to .msg[data-call-id=...].
    - showApproval() renders the .approval-dock DOM shape: .dhead count
      in a .dcount, one .dcall per pending call with .risk index pill +
      .dfn function name + .dargs preview.  approvalBar.hidden toggles
      visibility (the DS pattern is position: fixed and always-rendered;
      [hidden] is the show/hide hook).
    - setSseStatus() keeps .appbar-status as the base; semantic colour
      tracks OK / ERR via inline --ok / --err.  Leading glyph (●/○/⚠)
      preserves the WCAG 1.4.1 non-colour-only cue.
    - Wait indicator uses .appbar-status instead of the legacy
      .ts-header-status BEM; styling from the inline page rules colours
      it --think.

Contracts preserved:
  - SSE wire format and event names unchanged (approve_request,
    child_ws_created, wait_progress, batch_started, state_change,
    stream_end, ...).
  - POST /approve body shape unchanged: {approved, always, call_id}.
    No per-item feedback field is added (that's phase 9 PR C).
  - Keyboard behaviour unchanged: Enter continues to approve via the
    primary-button focus shift in showApproval(); the D and ⇧A kbd
    labels are rendered per the pattern spec but the global key
    handlers (if any) remain untouched.
  - ARIA attributes (role, aria-label, aria-live) preserved on the
    approval dock, messages log, and sidebar.
  - All shared-static JS imports and order unchanged; composer module
    continues to own its own DOM inside #coord-composer-mount.

No backend changes.  Legacy CSS (/shared/base.css, /shared/ui-base.css,
/shared/chat.css, /static/style.css) stays linked as the compatibility
layer — DS selectors [data-design="v1"] beat legacy where applied.

* fix(coordinator): inline approval dock above composer, not viewport-pinned

The .approval-dock DS pattern defaults to position: fixed; bottom: 22px
— designed for the fleet dashboard where the dock overlays content. In
the coordinator chat that rule pinned the dock to the viewport bottom,
covering the composer input area.

Move the dock DOM back inside #coord-main between #coord-messages and
the composer mount so it flex-stacks naturally above the input. Add a
view-local override that neutralises the fixed positioning (position:
static, z-index/box-shadow auto) while preserving the visual pattern
(warm top stripe, head/call/actions rows, dashed Always button).

Drop the 160px bottom-padding hack on #coord-messages since the dock
is now in-flow and naturally pushes the message log up.

Also likely resolves the Firefox initial-render issue — position:fixed
+ [hidden] toggle had cross-browser quirks where the dock wouldn't
appear on first SSE approval event until a separate DOM mutation
forced a reflow. In-flow layout makes it boring and predictable.

* fix(coordinator): integrate judge verdicts into approval dock, not chat

The judge's intent_verdict is evaluation context for the pending
approval, not a chat message. Previously each verdict appended a
"[judge] deny (risk=low)" tool message into the transcript even when
the corresponding approval was visible in the dock — two separate
surfaces showing related decision context, neither one complete.

Now:
- Each .dcall row gets data-call-id from the approve_request item
- intent_verdict looks up the matching row and renders a .dctx sibling
  below it with "judge: <recommendation> (risk: <level>)" + optional
  "confidence: <score>" chips. Reasoning attaches as title tooltip.
- Verdicts cache in a Map<call_id, verdict> so late-arriving
  approve_request events can still apply verdicts that came early
- Fallback to the old chat-message surface only when the approval isn't
  visible (call_id missing, or resolved before we could render) so the
  verdict isn't silently dropped

* feat(coordinator): judge verdict polish — colour-coded chips, spinner, reasoning

Three refinements to the approval-dock judge integration:

1. Colour-code verdict chips by recommendation — approve=green (--ok),
   review=amber (--warn), deny=red (--err). Reviewers can triage at a
   glance without reading the chip text; complements the text label
   for WCAG 1.4.1 (non-colour-only signaling).

2. Spinner while evaluating — when showApproval builds a .dcall row
   without a cached verdict, render a "judge evaluating…" chip with
   a spinner. Replaced in-place when intent_verdict arrives. Reuses
   the ts-spin keyframe from primitives/feed.css.

3. Justification inline — judge.reasoning is delivered in every
   intent_verdict event but was hidden behind a title tooltip. Now
   renders as a wrapped prose block (.drationale) below the .dctx
   chips, styled like the .msg-body .evi callout (left-rule + mono
   + --ink-3). Full text, no truncation — justification is the whole
   point.

View-local styling; the approval-dock pattern itself is unchanged.
If these patterns turn out to be broadly useful, they can promote to
shared_static/design/patterns/approval-dock.css in a later PR.

* fix(coordinator): defer approve-button focus until judge verdict arrives

The Approve button was getting focus the instant the approval dock
opened, which lit up the green focus ring and made the filled-green
button look pre-confirmed.  A reviewer could mistake that for "already
approved" before the judge has even returned a verdict.

Now focus is deferred until the intent_verdict for the first-pending
call arrives, then moves to:
  - Deny   button when judge recommends "deny"  (safety default)
  - Approve button for "approve" / "review" / anything else

Fallback timer (3s) claims focus anyway if no verdict arrives — covers
disabled judge and slow judge cases so keyboard users still land on a
button within a beat.

Focus claim is idempotent so batch approvals don't bounce focus across
buttons as trickling verdicts arrive.  hideApproval clears the timer
and the claimed flag so re-open cycles start fresh.

* fix(coordinator): drop approve-focus fallback timer

Previous commit added a 3s fallback that focused Approve if no verdict
arrived.  Ambiguous — a focus ring that lands "eventually" looks the
same as one that lands because the judge recommended approve.

Now focus only ever moves when a real intent_verdict arrives.  If the
judge is disabled or the verdict never comes, focus stays put and
keyboard users tab from the composer to reach the buttons.  An absent
focus ring is a clearer signal than an ambiguous one.

* fix(coordinator): address Copilot PR #393 feedback

Copilot findings, applied:

- Restore <h2> for Children / Tasks sidebar section labels (were
  changed to <span>).  .side-label class still applies; screen readers
  recover heading-level structure + rotor navigation.

- Mount the wait-indicator into #coord-header (the appbar container)
  instead of #coord-status.  #coord-status is reset via
  statusEl.textContent = ... on every state_change event, which was
  clobbering the wait indicator between ticks.  As a sibling inside
  the appbar, it survives state updates.

- Route `info` SSE events to appendText("info", ...) so they render
  with .msg.info (think-indigo) styling.  Prior routing to "tool"
  gave info events accent-tinted tool-call styling, miscategorising
  them visually.

- Define @keyframes ts-spin locally in the coordinator's <style>.
  Canonical definition lives in primitives/feed.css but this page
  doesn't link feed.css (no .feed-item usage), so the "judge
  evaluating…" spinner wasn't animating.

- Clear judgeVerdicts Map in hideApproval.  Map was growing unbounded
  across resolve cycles — fine for short sessions, leaks memory on
  long-lived coordinators with many approvals.

Not applied: Copilot's suggestion to restore focus-on-open or add a
fallback timer.  User explicitly requested no fallback — the design
decision is that the focus ring should only ever appear when the
judge has returned a verdict, so an absent ring reliably means "no
recommendation yet."  An auto-focus fallback would produce an
ambiguous ring that could be misread as "judge approved."
2026-04-20 20:10:03 -07:00
Patrick Buckley 412df28fe4 feat(design-system): DS phase 1 — chat primitives (.msg, .field, .appbar) (#391)
* feat(design-system): DS phase 1 — chat primitives for view migrations

Three new primitives enabling the chat-surface migrations (server UI +
coordinator):

  primitives/message.css   .msg + variants (user / assistant /
                           reasoning / tool / error / info / system),
                           .msg-meta author/timestamp slot, .msg-body
                           markdown target, .msg-actions hover-revealed
                           row, data-streaming="true" blinking caret.
                           Replaces .ts-msg* family in chat.css.

  primitives/field.css     .field wrapper with label/help/error, element
                           selectors for text/email/password/url/number/
                           search/tel/date/time/datetime/month/week +
                           textarea + select. .field.inline for checkbox/
                           radio rows, .field.invalid for error state.
                           Native-control focus-visible handled for
                           checkbox+radio so box-shadow ring remains
                           visible on unframed controls.

  chrome/appbar.css        chat-app header: back link + title + status +
                           action cluster. Distinct from the admin-style
                           .topbar (brand mark + nav + env metadata).
                           min-width:0 on .appbar-title so .dim subtitle
                           ellipsis fires under narrow viewports.

Preview.html extended with three demo sections exercising every variant
(plus a data-streaming example with live caret).

Fixes carried in from code review:
  - @media (hover: none) and (pointer: coarse) to match chat.css
    convention (hover-none alone is too broad, catches styluses)
  - .field-help uses --ink-3 (not --ink-4 which fails AA on --panel)
  - .msg-meta slot added so downstream PRs don't invent a custom class
  - Tool message pre/code on --panel-2 (parent is --panel; same-bg
    would make inline code disappear)
  - Checkbox/radio :focus-visible override (native controls lack a
    border for the default box-shadow ring to wrap)
  - Message.css comment corrected: "accent-tinted" not "cyan"

All rules scoped under [data-design="v1"]. Nothing existing modified.

* fix(design-system): address Copilot PR #391 feedback

- .msg-actions: add pointer-events: none when hidden, auto when visible.
  opacity:0 alone still intercepts clicks in the top-right corner —
  broke text selection on short one-line messages. Toggle applied in
  both hover/focus-within and the touch-media-query visible states.

- .field.inline comment: rewrite to match behaviour. Old comment said
  ".field stays flex-column" but the rule sets flex-direction: row.

- preview.html appbar demo: swap <a tabindex="0"> back-link to
  <button type="button">. tabindex-only anchors without href have
  inconsistent focus + screen-reader semantics; button is the correct
  native element for "navigate back via JS."
2026-04-20 16:50:28 -07:00
renovate[bot] 29d3953e52 chore(deps): update actions/setup-node digest to 48b55a0 (#390)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-20 16:44:59 -07:00
renovate[bot] a5d3e1b83c chore(deps): lock file maintenance (#372)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-20 16:44:40 -07:00
Patrick Buckley 2cfe6c23a0 refactor(design-system): post-#389 iteration — palette + glyphs + tint tokens
Live-preview-driven tuning pass following PR #389:

Palette
  - Accent hue 70 (amber) → 182 (teal). Amber collided with warn on
    same-surface k-badges; teal gives the brand accent its own hue.
  - ok / warn / err / think unified at L=0.50 light / L=0.68 dark and
    C=0.13-0.17 for palette coherence. err holds higher chroma so red
    doesn't wash; warn stays in the gold 80 lane (never 90+ / "puke").
  - Soft variants unified at L=0.94 / L=0.29, C=0.05-0.07.

New tokens
  --ok-live       brighter green for liveness signals (running dot)
  --ok-text       theme-aware text colour for filled green surfaces,
                  dark forest in light / bright mint in dark, ~7.5:1
                  against the approve-button bg in both themes
  --err-fill      darker red specifically for filled destructive
                  surfaces (.risk.crit) — bright --err as a fill
                  reads as alarm-loud
  --warn-tint,    directly-defined gold tints for k-tools k-badge —
    -tint-border  skips the color-mix-through-dark-cool-panel mud
                  that would otherwise render warm low-L mixes brown

Approve / Always / Deny
  - Approve filled green (color-mix --ok 28% into panel); text uses
    --ok-text for theme-correct contrast. Matches the pre-refactor
    turnstone/shared_static/chat.css convention where approve = green.
    Deviates from the Claude Design spec which had warn-tinted approve.
  - Always outlined dashed green (same --ok hue family); four non-colour
    cues for WCAG 1.4.1: fill state, border style, label, position.
  - Deny unchanged (err-outlined).

k-badge glyphs
  Replaced generic shapes with semantic symbols:
    tools  ⚙   approval ⚠\FE0E   policy §   role  ◉
    oidc   ⌘   token    ◆        judge  ⚖\FE0E  query ?
    step   ⇧   session  ◈        skill  ★   workstream ⇉
    fanout ⇶   default  ·
  ⚠ and ⚖ carry \FE0E to force text-presentation (avoid emoji
  promotion to coloured yellow triangle / blue scales on iOS Safari).
  token uses ◆ instead of ⬢ for universal font coverage.

k-approval split from k-tools
  k-tools stays gold (--warn family) — "tool call" kind.
  k-approval moves to green (--ok family) — matches the Approve button
  visually, completing the "⚠ approval → Approve" same-family story.

Running pill
  Text uses --ok (passes AA on pale --ok-soft); dot uses --ok-live +
  pulse. Liveness signal lives in the dot, not the text.

All changes stay under [data-design="v1"] — existing views untouched.
2026-04-20 16:30:39 -07:00
Patrick Buckley 78865c75d6 feat(design-system): DS-A + DS-B + DS-C — tokens, primitives, patterns (#389)
* feat(design-system): DS-A — tokens + typography scaffold

Adds turnstone/shared_static/design/{tokens.css,typography.css} as the
first phase of a multi-PR design refactor seeded by Claude Design.

- tokens.css: full palette + shape + rhythm, light default with
  [data-theme="dark"] override. oklch() raw colours, color-mix kept out
  of DS-A entirely (reserved for primitives in DS-B).
- typography.css: Inter + JetBrains Mono via Google Fonts; six-step
  scale (10/11/12/13/14/20-24). Utility classes .t-kicker/.t-meta/
  .t-btn/.t-row/.t-body/.t-stat/.t-h1.

Signature accent stays warm amber (oklch hue 70) rather than Claude
Design's teal — preserves turnstone's "Instrument Panel" identity.
All other tokens match the spec verbatim.

Additive: both files gate under [data-design="v1"] so existing views
(base.css, per-view stylesheets) are untouched. DS-B will opt views in
one at a time.

* feat(design-system): DS-B — chrome + primitives + preview page

Adds the reusable primitive kit that DS-C and DS-Cluster will build on:

  primitives/
    panel.css      .panel, .panel-head (.tools pinned right), .ghost
    buttons.css    .btn (pill 999px), .primary, .deny, .approve (amber)
    pills.css      .pill (running/thinking/attn/idle/err), .k-badge
                   (glyph-prefixed per WCAG 1.4.1), .chip, .risk
    stats.css      .stat + .stat-row, .mini-bar, .spark
    feed.css       .feed-item (grid ts/body/acts + .evi callout)
  chrome/
    topbar.css     48px sticky, conic-gradient brand mark
    sidebar.css    240px sticky, .shell layout, semantic swatches
  preview.html     renders every primitive in both themes with an
                   in-page theme toggle (tracks prefers-color-scheme)

Additive: every selector scopes under [data-design="v1"] so existing
views (base.css + per-view stylesheets) stay untouched.

Spec deviations from the Claude Design prototype:
- `color-mix(in srgb, …)` throughout; prototype had two `in oklab`
  usages — srgb per the spec's hard rule
- `.btn.approve` is warn-tinted amber, not green
  (approvals signal "needs attention"; amber resolves on approval)
- k-badge tint uses `color-mix` instead of oklch relative-colour syntax
  for broader browser support
- `@keyframes pulse/spin` renamed to `ts-pulse/ts-spin` to avoid
  clashing with keyframes in base.css on pages that load both
- `prefers-reduced-motion` disables pulse + spin animations
- Text-on-accent-soft + text-on-warn-tinted darkened via color-mix
  with ink to pass WCAG AA at 12px (fixes the classic same-hue trap)
- `.risk.crit` uses `#fff` text (dark-mode --panel on bright err fails)
- `.feed-item .acts button:not(.btn)` — compact action styling now
  skips .btn-classed buttons so they keep their pill shape
- Focus-visible rings on .btn, .ghost, .stat, .topnav, .side-item

* feat(design-system): DS-C — patterns (approval-dock, fleet-grid, live-feed)

Completes the design library with three patterns that compose primitives
into the signature product surfaces described in the Claude Design handoff.

  patterns/
    approval-dock.css   bottom-pinned approval strip. 1.5px-border,
                        --r-md squared action cluster: amber Approve
                        (primary), dashed Always, red Deny. kbd hints
                        and focus-visible rings on all three acts.
                        Call row (.dcall) framed as an inline code-
                        panel to emphasize "this is the exact call."
    fleet-grid.css      14-col grid of .node squares. State modifiers
                        (.s-ok/.s-thinking/.s-attn/.s-err/.s-idle/
                        .s-unreach) + --pct load fill. Hover uses
                        outline, not box-shadow (neighbour bleed is
                        the intended density cue). .fleet-legend
                        swatch row below.
    live-feed.css       thin scroll-container wrapper over the
                        .feed-item primitive with a sticky top fade.

  preview.html          imports the three patterns, extends the
                        fleet demo to use the real .fleet class +
                        legend, adds a live-feed panel, renders the
                        approval dock fixed at the bottom with
                        aria-live="polite".

Spec notes:
- Approve button is amber (warn-tinted), never green
- Dock action buttons are 1.5px-bordered 6px-radius squares — NOT
  pills — signaling "primary-action surface"
- All three dock actions clear WCAG AA in both themes via the same
  color-mix-with-ink darkening pattern used in .btn.approve
- kbd hint color matches primitives/buttons.css (--ink-3, not --ink-4)

View-level rewrites (coordinator.html + coordinator.js opt-in,
admin/cluster dashboard rebuild) are follow-up PRs — they need a
running server to test SSE streams + the approval POST contract.

* fix(design-system): scope DS-A tokens to [data-design="v1"]

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

* fix(design-system): scope DS-A font vars to [data-design="v1"]

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

* fix(design-system): align dark-mode selector with theme.js convention

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

* fix(packaging): add shared_static/design/** to wheel includes

Agent-Logs-Url: https://github.com/turnstonelabs/turnstone/sessions/74d0939a-c55f-46b0-92f4-14d0cbfb7084

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

---------

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-19 18:01:55 -07:00
Patrick Buckley a76d93b6c6 docs(coordinator): phase 8 PR C — API tour, skills guide, bulk-endpoints contract + wait diagram (#388)
* docs(coordinator): phase 8 PR C — API tour, skills guide, bulk-endpoints contract

Four deliverables that close out the phase 8 doc debt carried since
phase 1:

- docs/coordinator-api-tour.md — 9-step lifecycle walkthrough
  (create → subscribe → send → inspect children / detail → wait for
  fan-out → govern (trust / restrict / stop_cascade / close_all_children)
  → approve / cancel → close), one request + response per step, every
  SSE event type the UI has to handle, and every operation id cross-
  referenced against the live /openapi.json.  Integrators driving a
  coord session from a custom UI or SDK can work end-to-end from this
  doc without reverse-engineering the console page.

- docs/coordinator-skills.md — writing a SkillKind=COORDINATOR skill.
  Tool-surface diff (13 orchestration tools, no bash / edit / web /
  sub-agent), persona diff (orchestrator vs maker, composing on
  base_coordinator.md), SkillKind enum + migration 044, task_list
  integration, ws_id handling, wait vs inspect cost profile, three
  orchestration patterns (delegate-and-summarise, fan-out-and-
  synthesise, plan-then-delegate), testing surface.

- docs/bulk-endpoints.md — codifies the two shape idioms that shipped
  across phases 6–8: {results, denied, truncated} for bulk-read /
  bulk-create-with-payload (cluster/ws/live, spawn_batch); {<bucket>,
  failed, skipped} for cascade-mutation (stop_cascade,
  close_all_children).  Picks-by-semantics guidance so the next bulk
  endpoint author doesn't coin a third shape.

- docs/diagrams/27-coordinator-wait-for-workstream.puml + rendered
  PNG — sequence diagram covering spawn → wait (blocking, with
  bounded progress emission) → inspect → close.  Embedded in the
  API tour doc's §6 so the "why is my coord session blocking?"
  question has a visible answer.

No code changes.  All operation ids in the API tour verified against
a live build of the console spec; all markdown internal links
resolve; PlantUML renders clean on the system plantuml jar.

* docs(coordinator): address PR #388 copilot review

- api-tour.md child-event payload keys: events stamp `ws_id` as the
  coord's own id and carry the child's id separately as
  `child_ws_id`.  Doc previously listed `ws_id` as the child
  identifier on all four child_ws_* events, which would send SDK /
  UI implementers parsing the wrong field.
- api-tour.md SSE table: add the `status` event emitted by
  ConsoleCoordinatorUI.on_status (token usage + context_window +
  effort snapshot; fires on every streaming tick).  Previously
  omitted from the "every event type a UI has to handle" list.
- api-tour.md /children response key: server returns `{items,
  truncated}`, not `{children, truncated}`.  Also drop the
  `state=closed` query-param claim — the endpoint has no state
  filter; clients filter locally on the returned `state` field.
- skills.md task_list shape: the persisted row uses `id` (not
  `task_id` — the input schema uses `task_id`, the row uses `id`),
  has `child_ws_id` / `created` / `updated` (no `notes` field),
  and supports a 5th `reorder` action alongside add/update/remove/
  list.  Adds the parallel-dispatch caveat from the tool
  description.
- skills.md tenant-guard behaviour: foreign / hallucinated ws_ids
  don't return an empty result — they return explicit
  error/not-found/denied shapes that differ by op (mutating ops
  return `{error, status: 404}`; inspect returns `{error}`; wait
  reports state=denied).  Important distinction — a skill that
  expects empty on mismatch will mishandle every single case.

Docs-only; no code / schema / SDK changes.  All internal links
still resolve.
2026-04-19 10:15:08 -07:00
Patrick Buckley 67aaa236e8 feat(coordinator): phase 8 PR B — spawn budget + rate limit + /quota endpoint (#387)
* feat(coordinator): phase 8 PR B — spawn budget + rate limit + /quota endpoint

Adds two complementary controls so a runaway coordinator can't saturate a
cluster's max_active without anyone noticing:

- **Spawn budget** (hard quota) — cap on concurrently active children.
  Default 20 per coord.  spawn_workstream returns a tool error guiding
  the model to close idle children; spawn_batch routes overflow rows to
  `denied[]` with partial-success semantics.
- **Spawn rate limit** (soft pacing) — classic token bucket, defaults
  5 tokens/minute with burst 10.  A rate-limited spawn surfaces a tool
  error carrying `retry after Ns` so the model paces itself.  Zero
  refill rate is honoured as "disable refill" (bucket still honors the
  initial burst).

Shipped infra:

- `turnstone/core/spawn_quota.py` — thread-safe `SpawnBudget` +
  `TokenBucket`.  15 unit tests.
- `turnstone/core/session.py` — coord-only state built from settings at
  __init__.  Shared `_eval_spawn_quota(active)` helper drives both the
  single-spawn path (wraps the denial reason in `_coord_tool_error`) and
  the batch path (annotates `spec["_error"]`).  `_count_active_children`
  routes through `coord_client.list_children(include_closed=False)` and
  fails *open* on lookup error (budget is operator-safety, not security).
- `POST/GET /v1/api/coordinator/{ws_id}/quota` — partial-update admin
  endpoint mirroring the /trust + /restrict shape.  Accepts either the
  nested `spawn_rate` object or flat aliases — supplying both for the
  same field returns 400 so the admin UI can't half-migrate silently.
  Overrides are in-memory only (die on session reopen).  Audits via
  `coordinator.quota.updated` with before/after snapshots.
- Settings: `coordinator.spawn_budget`, `coordinator.spawn_rate.tokens_per_minute`,
  `coordinator.spawn_rate.burst` with ranges 1..500 / 0..600 / 1..500.
  The range bounds are the single source of truth — the endpoint
  validators and Pydantic schema both import from `settings_registry.SETTINGS`
  so bumping a cap in one place lights up everywhere.
- OpenAPI: `CoordinatorQuotaRequest` / `CoordinatorQuotaResponse` /
  `CoordinatorSpawnRateState` schemas + endpoint specs.  TS SDK regenerated.

Tests: +15 unit (SpawnBudget + TokenBucket), +17 endpoint (GET + POST
happy paths, range edges, mixed-body rejection, non-object spawn_rate,
service-token refusal), +11 session-side (budget blocks single spawn,
budget batch partial-success, rate batch partial-success, empty-body
reject, mutator live-update, non-coord session has no quota state).

Deferred (not this PR): per-skill scoping via migration 047 +
`prompt_templates.spawn_budget` column.  Count-only storage helper
(opportunistic — list_children at budget ≤ 500 is fine behind a
human-gated approval flow).

* fix(coordinator): address PR #387 copilot review

- Budget undercount: _count_active_children used list_children's
  LIMIT-then-Python-filter path, so a fan-out with many recently-closed
  children could push live rows past the SQL LIMIT and silently
  undercount, leaking spawn slots past the budget.  Replace with a new
  CoordinatorClient.count_active_children that uses
  storage.count_workstreams_by_state (SQL aggregate, no pagination,
  sums non-terminal states).  Tenant-guarded; fails open on storage
  error (budget is operator-safety, not a security gate).  New client
  tests cover the non-terminal count, the closed/deleted exclusion,
  the foreign-parent guard, and the fail-open path.
- Service-token bypass on /quota: both GET and POST used the default
  allow_service_bypass=True, so a service token whose user_id matched
  the coord owner could read or *raise* spawn capacity without the
  explicit admin.coordinator grant.  Flip both to
  allow_service_bypass=False for consistency with /restrict,
  /stop_cascade, and /close_all_children.
- OpenAPI contract leak: CoordinatorSpawnRateState was used for both
  the request and response shapes, which let generated SDKs imply
  clients could POST tokens_available (a read-only bucket reading the
  handler ignores).  Split into CoordinatorSpawnRateInput (request:
  tokens_per_minute + burst only) and CoordinatorSpawnRateState
  (response: adds tokens_available).  No runtime behaviour change;
  SDKs regenerate with two distinct types.

Drops the _ACTIVE_COUNT_SLACK / _ACTIVE_COUNT_MIN_LIMIT constants in
session.py — no longer needed since the new helper takes no limit
argument.  Updates the 5 session-side quota tests to stub
count_active_children instead of list_children.
2026-04-19 10:14:26 -07:00
Patrick Buckley 7d61f9a37c feat(coordinator): phase 8 PR A — spawn_batch + close_all_children batch tools (#386)
* feat(coordinator): phase 8 PR A — spawn_batch + close_all_children batch tools

Adds two model-facing batch tools so a coordinator can fan out without burning one approval per child:

- `spawn_batch` — create up to 10 child workstreams in a single approval. Serialised
  spawns so sibling ordering (by created_at) stays deterministic. Returns
  `{results: {idx: {ws_id, name, node_id, status}}, denied: [{idx, reason}]}`.
  Per-item validation / spawn failures surface in `denied[]`; the batch hard-errors
  on >10 rather than silent truncation.
- `close_all_children` — soft-close every direct child in one approval. Server-side
  Sem(16) fan-out via `coord_client.close_workstream`; `reason` propagates to every
  closed child's audit + workstream_config. Response mirrors `stop_cascade`'s cascade
  idiom: `{closed, failed, skipped}` where `skipped` is upstream-404 / already-gone.

Shipped infra:

- New console endpoint `POST /v1/api/coordinator/{ws_id}/close_all_children`
  (gated `admin.coordinator`, `allow_service_bypass=False`, 512-char reason cap,
  `coordinator.closed_all_children` audit).
- Shared `_fanout_on_children` helper — both `stop_cascade` and `close_all_children`
  now delegate to it (one place to own the snapshot → semaphore-gather → bucket-split
  skeleton).
- `CoordinatorClient.close_all_children(reason)` plus a `_post_url` seam that
  `_post` now reuses (no more duplicated transport-error handling).
- `_emit_batch_event` — best-effort SSE emitter modelled on `_emit_wait_event`.
  Emits `batch_started` / `batch_ended` pairs keyed by call_id. Throttled
  `batch_progress` deferred to a follow-up.
- OpenAPI request + response schemas, endpoint spec entry, TS SDK regenerated.
- Persona doc (`tools_coordinator.md`) covers the two new patterns.

Bulk-endpoint shape policy (codified in PR C later): split by semantic category —
`{results, denied, truncated}` for bulk-read / bulk-create-with-payload (cluster/ws/live,
spawn_batch), `{<bucket>, failed, skipped}` for cascade-mutation (stop_cascade,
close_all_children). No retrofit needed on stop_cascade.

Tests: new `test_coordinator_close_all_children.py` (8 endpoint tests), expanded
`test_coordinator_tools.py` (session-side prepare/exec, coord_client=None guards,
batch SSE events), expanded `test_coordinator_client.py` (route map, client method,
transport errors), tool-count assertions updated.

Deferred (not this PR): per-item selective-deny approval UI, throttled batch_progress
SSE, coordinator-skills doc + bulk-endpoints doc (PR C), spawn budget / rate limit (PR B).

* fix(coordinator): address PR #386 copilot review

- coordinator_client.close_all_children: pass the unformatted path template
  as log_path so telemetry aggregates don't fragment per session (ws_id
  still lives in the real URL).
- session.py: drop dead spawned_ids accumulator in _exec_spawn_batch —
  leftover from an eager-register path that got removed earlier.
- close_all_children tool JSON: document the 512-char server-side cap on
  reason and that reason is echoed back in the response payload.  Added
  maxLength:512 on the schema property so the LLM sees the constraint.
- CoordinatorCloseAllChildrenRequest: add Field(max_length=512) so the
  OpenAPI schema reflects the runtime 400-on-overflow constraint.
2026-04-18 23:50:25 -07:00
Patrick Buckley a7be0e1610 chore: bump version to 1.5.0a2 2026-04-18 21:15:24 -07:00
Patrick Buckley 7f40141c16 Feat/composer ux squashed (#385)
* refactor(ui): shared composer widget (pane / coordinator / coord-create)

The interactive workstream pane (turnstone-server), the coordinator
session view (turnstone-console), and the console home's "start a new
orchestration task" form had drifted into three unrelated composer
implementations with different DOM, different class names, and
different behaviour sets.  All three now build on a single
`shared_static/composer.js` widget parameterised by feature flags.

The widget owns the textarea, send button, optional stop button,
optional attach button + file input + chip container, optional
drag-drop / paste-image wiring, optional queue-while-busy send-label
rotation, optional touch-aware Enter-to-send, and an optional
collapsible Options panel with input / select fields, live summary
chip, and localStorage-persisted open/closed state.  A stacked layout
puts the textarea above the action row for creation-form consumers;
the inline layout keeps the chat-style single row for send composers.

Consumer wiring:

- Pane: attachments + stopBtn + queueWhileBusy + drag-drop; keeps
  its own attachment-upload pipeline and routes file events through
  the composer's onAttach callback.  Pane-specific CSS (.pane-stop
  / .pane-send.queue-mode) retired; `.ts-composer-stop` / `.ts-
  composer-send--queue` in shared/chat.css take their place.
- Coordinator send: just textarea + send with touchEnterSends=true
  to preserve the pre-refactor tap-to-send behaviour on tablets.
  The header-mounted coord-cancel-btn stays (different semantics
  than a per-generation stop).
- Coord-create: stacked layout, rows=3, Start-labelled send, Options
  dropdown holding Name + Skill; Ctrl/Cmd+Enter handler scoped to
  the composer mount.  `_createCoordinator` lost its DOM-ref shape
  in favour of raw values + a setBusy callback; a single
  `_refreshHomeCoordSubmitEnabled` reconciler owns the submit
  button's disabled flag so the 503 probe and in-flight submit
  can't race each other.

Shared chat.css grew the .ts-composer-stop, .ts-composer-options-*,
and .ts-composer--stacked blocks; the coord-create consumer dropped
its custom .home-composer-task/-row/-name/-skill/-submit selectors
and the "Start a new orchestration task" panel title so the
placeholder text carries its own context, matching the webui
dashboard's clean look.

The visible behaviour on each surface is intentionally the same as
before; the change is structural — the three composers can no longer
drift apart silently.

* fix(composer): review fixes — Enter guard, single disable owner, widget-owned stop reset

Review of the squashed whole caught issues that the piecewise reviews
missed because they only become visible with all three consumers
together:

- **Enter bypassed sendBtn.disabled.**  Composer's Enter keydown
  handler called _fireSend() without checking sendBtn.disabled.  In
  the coord-create flow submitHomeCoord doesn't clear the textarea
  before the POST completes (it redirects on success), so two rapid
  Enter presses both fired _createCoordinator and could create two
  coordinators.  Enter now mirrors the click path.
- **Two writers to sendBtn.disabled.**  Composer.setBusy and
  _refreshHomeCoordSubmitEnabled both wrote the flag.  They agreed
  in sequence today but it was the exact drift hazard the reconciler
  was meant to eliminate.  Added an externalDisable option; when
  true Composer's setBusy rotates labels / placeholder / stop button
  but leaves sendBtn.disabled to the caller's reconciler.  The
  coord-create composer opts in.
- **setSendLabel dead weight.**  Called on every busy transition
  with static "Start" / "Starting…".  Composer.setBusy now rotates
  labels universally (not just in queueWhileBusy mode); the busy
  label goes at construction via the existing busyLabel option and
  setSendLabel is removed.
- **Pane reached through composer to reset stopBtn.**  Pane.setBusy
  was writing stopBtn.textContent / aria-label / dataset after
  delegating — internals leaking through.  Composer.setBusy now
  resets the stop button's standard label + clears forceCancel on
  every transition (matching the comment that used to live in Pane);
  Pane drops the reach-through.
- **destroy() left detached DOM reachable.**  Back-refs (inputEl,
  sendBtn, etc.) are nulled out so post-destroy access fails loudly
  instead of silently mutating detached nodes.
- **_maybeAutoResize dead indirection.**  The enabled-check folded
  into autoResize itself.

* fix(composer): busyLabel context-sensitive default + busyPlaceholder universal

Round-two review caught three related loose ends:

- Default `busyLabel="Queue"` was fine when label rotation was queue-
  mode-only, but became misleading after the earlier review fix made
  rotation universal: non-queue consumers calling setBusy(true)
  without explicit busyLabel would flash "Queue" on the disabled
  button.  Default is now context-sensitive — "Queue" when
  queueWhileBusy=true, sendLabel otherwise (no rotation).  The
  coord-send composer no longer needs to touch the label at all.
- `busyPlaceholder` JSDoc implied universal swap on busy but the
  implementation gated it on queueWhileBusy.  Decoupled — the
  placeholder swaps whenever busy, with callers that don't set
  busyPlaceholder seeing no visible change because it defaults to
  the idle placeholder.
- `options.toggleLabel` and `options.onChange` were supported by the
  implementation but undocumented.  JSDoc for the options shape
  enumerates every supported key + its default.
2026-04-18 21:11:08 -07:00
Patrick Buckley 7c16b0dfa8 refactor(routing): replace hash-ring rebalancer with rendezvous (HRW)… (#384)
* refactor(routing): replace hash-ring rebalancer with rendezvous (HRW) hashing

Routing was a stored bucket table maintained by a central rebalancer
daemon, which shared its liveness primitive (services.last_heartbeat)
with the collector — when a heartbeat-fresh node went into a zombie
HTTP-handler-broken state, neither the collector nor the rebalancer
could self-correct, and the router kept directing traffic at it.
Rendezvous hashing makes the route a pure function of (ws_id,
live_services) so the heartbeat is the single source of truth and any
liveness-eviction propagates to the next route call without a separate
state-publication step.

The rebalancer's central state has no analogue: the new router computes
the per-key node winner on every call, the collector pushes membership
updates into the router cache from its discovery thread, and per-route
overrides survive on workstream_overrides. Eager workstream migration
goes away; in-flight workstreams lazily rehydrate from storage on the
new owner — already the dead-node behaviour.

* fix(tools): describe rendezvous re-routing on spawn/inspect node_id

The first pass overclaimed `node_id` "stays canonical for this
workstream's lifetime" — under rendezvous routing the active owner
re-derives per-call from live membership, so a node join/drop after
spawn can shift it.  Tool descriptions now say `node_id` is the
spawn-time binding; subsequent ops re-route via rendezvous over the
current live-node set; the new owner lazily rehydrates from shared
storage; coordinators should re-read with inspect_workstream rather
than caching the value.
2026-04-18 19:02:52 -07:00
Patrick Buckley 9826ea15c5 feat(coordinator): phase 7 — governance + skill metadata + cross-cutt… (#383)
* feat(coordinator): phase 7 — governance + skill metadata + cross-cutting invariants

Combines three stacked sub-PRs into a single coordinator phase-7
shipment against the phase-7 plan doc.  The sub-PR structure (0 / A /
B) preserved on individual branches for reviewer drill-down; this
branch is the one reviewers should merge.

## Sub-PR 0 — service-auth boundary invariants

Shared helpers and contracts that lock the console ↔ node service-auth
boundary so later authz surfaces use them by construction.

- ``_effective_user_filter(request)`` in both ``turnstone.console.server``
  and ``turnstone.server`` with a shared ``DENY_EMPTY_SUB`` sentinel
  on ``turnstone.core.auth``.  Three-way return — admin/service
  bypass, scoped caller uid, or fail-closed sentinel on blank sub.
  Four callsite migrations (``_coordinator_rows``,
  ``coordinator_children``, ``coordinator_metrics``,
  ``cluster_ws_live_bulk``).

- ``StorageBackend`` class docstring codifies the tenancy contract
  (every list/count/aggregate method must accept ``user_id: str |
  None = None`` and push ``WHERE user_id = :user_id`` into SQL) and
  the ``_mapping`` row-access contract.  New
  ``turnstone.testing.row_contract`` ships ``assert_row_like()``.

- ``_verify_collector_service_scope`` probes an upstream node at boot
  with ``expected_node_id=_scope-probe_``; a 409 proves the scope
  gate was passed, a 403/401 sets ``collector_scope_error`` and
  causes ``cluster_snapshot`` / ``cluster_events_sse`` to return 503
  with a remediation hint.  Probe URL allowlist rejects non-http(s)
  schemes and 169.254.0.0/16 hosts.

- 4xx log-level floor on ``_NodeDashboardCache.get``,
  ``_fetch_live_block``, and ``_proxy_sse`` — dotted-hierarchy
  prefixes with bounded body previews.  ``_bounded_body_preview`` and
  ``_bounded_stream_preview`` strip control chars.

## Sub-PR A — coordinator governance core

Mid-session governance surface for coordinator workstreams.

- **Trusted-session mode.**  New ``coordinator.trust.send``
  permission (migration 042).  ``ChatSession.set_trust_send`` /
  ``revoke_tools`` methods with a ``_governance_lock``.  ``POST
  /v1/api/coordinator/{ws_id}/trust {send: bool}`` double-gated on
  ``admin.coordinator`` AND ``coordinator.trust.send`` with
  ``allow_service_bypass=False`` so service tokens can't escalate.
  ``_prepare_send_to_workstream`` auto-approves sends whose target is
  in the coordinator's own subtree; foreign ws_ids still require
  approval.  ``_is_own_subtree`` checks both ``parent_ws_id`` AND
  ``user_id`` to defend against cross-tenant row corruption.

- **Audit-layer credential redaction.**  ``record_audit`` walks
  ``detail`` (dicts, lists, tuples, sets, frozensets; keys too)
  and routes every string through ``redact_credentials`` + a C0
  control-char scrub.  New kw-only ``raw_detail=True`` opt-out.
  ``_has_any_string`` fast-path.  Audit action registry extended
  with the four new governance sub-prefixes.

- **Mid-session revocation + cascading stop.**  ``POST
  /v1/api/coordinator/{ws_id}/restrict {revoke: [...]}`` caps 256
  entries / 128 chars; ``_prepare_tool`` short-circuits with a
  tool-error.  ``POST /v1/api/coordinator/{ws_id}/stop_cascade``
  cancels the coord's in-flight generation then dispatches
  ``cancel_workstream`` for every direct child in parallel via
  ``asyncio.gather`` bounded by ``Semaphore(16)``.  Per-child
  outcomes split into ``cancelled`` / ``failed`` / ``skipped``
  (404 = already-gone rather than dispatch-broken).  Both endpoints
  apply ``allow_service_bypass=False`` on the admin gate.

- **Shared plumbing.**  ``_resolve_coord_session`` helper collapses
  the handler prelude three endpoints shared.  ``_emit_coord_audit``
  wraps ``record_audit`` in a dedicated ``ThreadPoolExecutor``
  (``app.state.audit_executor``) so audit bursts don't starve cancel
  dispatches.  ``_require_json_object`` guards body parsing so non-
  object JSON returns 400 instead of 500.

## Sub-PR B — skill metadata governance

- **Description validator (migration 043).**  ``prompt_templates``
  rows now require a non-empty ``description``.  Existing empty rows
  get backfilled with a ``"Skill: <name>"`` placeholder on upgrade.
  The installer (``admin_skill_discover``) and MCP prompt sync both
  synthesise a placeholder when the upstream description is blank
  so non-admin write paths satisfy the invariant.

- **Skill kind classifier (migration 044).**  New
  ``prompt_templates.kind`` column (``interactive`` / ``coordinator``
  / ``any``; defaults to ``any``).  New
  ``turnstone.core.skill_kind.SkillKind`` StrEnum is the single
  source of truth; Pydantic schemas type ``kind`` as ``SkillKind``
  (OpenAPI advertises the enum) and the handler validator catches
  the ValueError.  ``list_skills_filtered`` gains a
  ``kinds: list[str] | None = None`` SQL filter.
  ``CoordinatorClient.list_skills`` defaults to
  ``kinds=["coordinator", "any"]`` so interactive-only skills are
  hidden from the orchestrator.

- **``scan_status`` → ``risk_level`` rename (migration 045).**
  Lossless column rename to align with ``IntentVerdict.risk_level``
  terminology.  Swept storage (both backends + schema + protocol),
  handlers, API schemas, tool JSON, generated OpenAPI specs,
  TypeScript SDK types, frontend (``governance.js``), tests, and
  English prose in ``docs/judge.md`` + ``docs/tools.md``.  The
  user-facing on-load warning now reads ``has risk level:
  {risk_tier}``.  Tool JSON's ``risk_level`` enum corrected to the
  scanner's actual taxonomy (``safe / low / medium / high /
  critical``; was the never-shipped ``clean / flagged / unscanned /
  pending``).  Historical migration 021 left untouched.

## Migrations

042 (``coordinator.trust.send`` perm — PR A)
043 (description backfill — PR B)
044 (``kind`` column add — PR B)
045 (``scan_status`` → ``risk_level`` rename — PR B)

All four use position-anchored permission strings / host-side
parse-filter-rejoin on downgrade where SQL ``REPLACE`` could
corrupt prefix-overlapping values.

## Verification

- ``ruff check turnstone tests`` clean.
- ``mypy turnstone`` clean on 165 source files.
- ``pytest -m "not live"``: 4431 passed (+85 over the phase-6
  baseline).  Includes +32 tests in ``tests/test_service_auth_boundary.py``
  and +38 in ``tests/test_coordinator_governance.py``; shared fixtures
  extracted to ``tests/_coord_test_helpers.py``.
- Generated OpenAPI JSON (``sdk/typescript/openapi-{console,server}.json``)
  regenerated via ``sdk/typescript/scripts/generate-types.py``; zero
  ``scan_status`` occurrences remaining outside the historical
  migration 021 and the rename migration 045.

## Security reviews

Both reviews flagged by the phase-7 plan (items 1 + 5, plus 0a's
refuse-to-serve gate) ran through the multi-stage ``/review``
pipeline twice per sub-PR; all confirmed findings landed in-branch.

* fixup(phase-7): CI lint + PR #383 review fixups

Addresses the lint CI failure (ruff format) plus 12 findings from the
two automated PR reviewers.

Copilot:
- ``_sqlite.list_installed_skill_urls`` / ``_postgresql.list_installed_skill_urls``
  used positional row indexing (``r[0]``/``r[1]``/``r[2]``) while this
  same PR's ``StorageBackend`` class docstring forbids it.  Switched
  both to ``r._mapping["..."]`` access.
- ``list_skills.json`` previously advertised ``risk_level=""`` as a
  filter for unscanned skills, but the implementation treats empty
  strings as "no filter".  Clarified the tool description to say
  omit the filter entirely to include unscanned rows, and added an
  explicit ``enum`` on the parameter restricting it to the scanner
  tiers.  ``_prepare_list_skills`` keeps the ``strip() or None``
  normalisation — unscanned filtering now has an unambiguous contract.
- ``test_storage_skills_filtered.test_risk_level_filter`` used the
  legacy ``clean`` / ``flagged`` values from the pre-rename column.
  Rewritten with the scanner's actual taxonomy (``safe`` / ``high``).

github-code-quality (CodeQL):
- ``test_deny_sentinel_is_singleton`` previously asserted
  ``cs.DENY_EMPTY_SUB is cs.DENY_EMPTY_SUB`` — an identical-expression
  comparison.  Rewritten as two separate ``from ... import ... as`` aliases
  (``FIRST_READ`` / ``SECOND_READ``) so the identity check is between
  distinct bindings.
- ``test_restrict_empty_revoke_is_noop_but_audits`` unpacked ``state``
  without using it.  Renamed to ``_state``.
- Mixed import styles in ``test_service_auth_boundary.py`` — the
  file previously used both ``import turnstone.console.server as cs``
  and ``from turnstone.console.server import ...`` for the same
  module (same story for ``turnstone.core.auth`` and
  ``turnstone.server``).  Consolidated to the ``from X import Y`` style
  used elsewhere in the file; the ``_fetch_live_block`` test now
  patches via pytest's ``monkeypatch`` fixture instead of a manual
  rebind through a module alias.

CI:
- ``ruff format`` reformatted one line in
  ``tests/test_coordinator_endpoints.py``.

Verification: ruff check + mypy clean (166 files); 4459 non-live
pytest pass.

* fix(tests): swap asyncio marker for anyio in service-auth boundary tests

PR #383 CI caught that the 13 ``@pytest.mark.asyncio`` decorators I
added in ``test_service_auth_boundary.py`` are an off-convention
choice — the rest of the repo uses ``@pytest.mark.anyio`` (148 sites
vs my 13).  The CI environment pulls in ``anyio`` but not
``pytest-asyncio``, so every async test in this one file was failing
with "async def functions are not natively supported".  It passed
locally by accident — my dev venv happens to have pytest-asyncio
installed ambiently.

Swapped all 13 marker sites to ``@pytest.mark.anyio``.  No functional
change; the tests run under the same default asyncio backend anyio
provides.

Verification: ruff + mypy clean (166 files); 4459 non-live pytest
pass.
2026-04-18 10:20:19 -07:00
Patrick Buckley cab57f244d refactor(channels): backfill review of Slack/Discord adapters (#382)
* refactor(channels): backfill review of Slack/Discord adapters

Retrospective multi-stage review of the Slack (PR #355) and Discord
channel adapters — they shipped before the review pipeline existed,
so this pass goes back and fixes everything the pipeline would have
caught plus a follow-up round of ultrareview findings.

## Security (8 fixes)

- Adapter-side owner checks on all interactive flows: Discord
  ApprovalView / PlanReviewView encode the owner Discord user ID in
  the embed footer (`{ws_id}|{corr_id}|{owner_id}`) and reject
  non-owner clicks; Slack plan-approve / request-changes /
  feedback-modal gain owner tracking in `_pending_plan_review_ts`
  and a shared `_ensure_plan_review_owner` gate.  These closed the
  two critical authz gaps where the gateway's service-scoped JWT
  bypassed server-side ownership checks.
- Discord thread-message gate: only the registered invoker can
  drive the workstream (prevents a linked user posting in another
  user's public thread from injecting into their assistant).
  Invoker recorded explicitly so `/ask` follow-ups survive the
  `channel.create_thread` bot-as-owner quirk.
- Slack /link flow + per-user identity gate: unlinked Slack users
  see an ephemeral `/turnstone link <token>` prompt on every
  message instead of silently creating workstreams under the
  shared gateway identity.  Rate-limited (5/hour) to block online
  token enumeration.
- Gateway `/v1/api/notify` requires `write` scope on the validated
  JWT; low-scope tokens get 403 + audit.
- Thumbnail URL validator DNS-resolves the hostname before fetch
  and rejects any resolved IP that's loopback / link-local /
  multicast / reserved, plus an explicit deny-list for IPv6 cloud
  metadata (`fd00:ec2::/32` — AWS Nitro IMDS + ECS task metadata)
  that would otherwise slip past the `is_private` allowance.
- Per-user rate limit (10 msgs / 60s) + 8 KiB inbound size cap on
  Slack DMs / channels / notification-reply threads so one user
  can't exhaust the shared LLM budget.
- Discord /link rate limit (5/hour) for token-enumeration defense.

## Bug fixes (9 correctness issues)

- Slack DM routing: each top-level DM no longer spawns a fresh
  workstream (was using per-message `ts` as the route key).
- Multi-chunk Slack responses thread correctly under the first
  chunk's ts instead of fragmenting as independent top-level
  messages.
- Finalize the outgoing StreamingMessage before swapping channel /
  thread_ts mid-stream, so buffered tokens still land on the old
  thread.
- Redundant `chat_update` on approve/deny eliminated by popping
  `_pending_approval[ws_id]` after local resolution.
- Notification reply tracking on Discord only registers for DMs
  (guild-channel targets were storing channel IDs where user IDs
  were expected, so legitimate replies were always rejected).
- `get_channel_default_alias` rolls `_channel_default_ts` back on
  `list_models()` failure so the next caller retries instead of
  serving an empty alias for the full TTL.
- Slack `subscribe_ws` purges dead SSE tasks before the
  membership short-circuit (previously an unhandled exception left
  the ws_id in `_subscribed_ws` forever, silently no-opping
  subsequent subscribes).
- ChannelRouter `_create_locks` is now an LRU-bounded OrderedDict
  that evicts only unheld locks (original dict grew unbounded;
  naive LRU could evict a held lock and let a second caller race
  through the critical section, creating duplicate workstreams).
- Slack `_parse_ts` pads the fractional field to 6 digits so
  `"1.2"` and `"1.000002"` stop colliding as `(1, 2)` in the
  latest-session tiebreaker.

## Performance (6 fixes)

- StreamingMessage keeps a rolling truncated display string capped
  at `max_length` so per-flush cost is O(max_length) instead of
  O(total_streamed_chars) — long streaming responses no longer do
  quadratic work every edit interval.
- `StreamingMessage.finalize()` caches the joined content so the
  Discord stream-end DM-forward path doesn't re-join a multi-MB
  buffer twice.
- `PendingApproval` stores the Block Kit payload posted to Slack;
  `IntentVerdictEvent` appends the verdict in-place and
  `chat_update`s, skipping an extra `conversations_history`
  round-trip.
- ChannelRouter `lookup_ws_id()` TTL-caches the channel →
  ws_id resolution (30s TTL, 4096-entry LRU); hot inbound paths
  skip storage on every message.
- Service-discovery startup retry uses exponential backoff
  (1s → 8s cap) with a 30s deadline instead of 30 × 1s fixed
  sleep.
- `_archive_session` now calls `router.close_workstream` so the
  `_node_urls` cache entry is dropped (was leaking one entry per
  archived session).

## Quality / refactors (19 improvements)

- `cli.main()` extracted from a 365-line function into focused
  helpers; imports carefully kept lazy where test patches target
  source-module paths.
- `_run_gateway` finally block now awaits `adapter.stop()` on
  every adapter so SSE tasks, httpx clients, and the Slack socket
  handler close cleanly on shutdown.
- Shared SSE reconnect loop extracted to `turnstone/channels/_sse.py`
  (`run_sse_stream` with `on_event` + `on_stale` callbacks); both
  adapters' `_sse_listener` methods just wire up callbacks. The
  "404 stops reconnect" invariant is enforced inside the helper
  so a broken `on_stale` can't livelock.
- `_on_ws_event` god-dispatchers split into per-event `_handle_*`
  methods with a thin isinstance dispatcher at the top.
- Slack `_on_approve` / `_on_deny` collapsed into a single
  `_resolve_approval(*, approved: bool)`.
- `ApproveRequestEvent` policy evaluation hoisted into
  `ChannelRouter.evaluate_tool_policies` returning a
  `PolicyVerdict`; adapters switch on the verdict kind.
- `ChannelAdapter` protocol trimmed to the four methods adapters
  actually implement; unused `ChannelEvent` dataclass removed.
- Shared constants lifted to `turnstone/channels/_config.py`.
- `_cleanup_stale_route` and `unsubscribe_ws` share a
  `_clear_ws_state` helper.
- `StreamingMessage` private attrs promoted to `message` /
  `message_ts` / `accumulated_text` properties so callers don't
  reach past the `_`-prefix.
- Various cleanups: dead var, noqa'd lambdas, renamed
  `_policy_handled` → `policy_handled`, inlined single-use
  helpers, added module docstrings, documented
  `SlackRoute.parse` edge cases.
- `chunk_message` plain-text fast path (no backticks → skip
  fence bookkeeping).

## Test coverage

Added 45 tests (178 → 223):

- `tests/test_channel_sse.py` (new) — SSE reconnect / backoff /
  404-stale-route / on-stale-exception / invalid-JSON-skip /
  on-event-exception-doesn't-kill-stream / per-connection token
  refresh / ConnectError retry.
- ApprovalView + PlanReviewView owner-check regression tests
  (owner allowed, non-owner rejected, legacy 2-pipe footer fails
  closed, modal path rejected for non-owner, `/ask`
  bot-as-thread-owner follow-up allowed).
- Slack `_recover_routes` latest-ts-wins, `_archive_session`
  drops route + closes workstream.
- SSRF tests: DNS rebinding rejected, IPv4 link-local metadata
  rejected, IPv6 ULA metadata (fd00:ec2::254 / fd00:ec2::23)
  rejected.
- Slack link prefix match (natural-language prompts don't
  hijack), link rate-limit ceiling.
- SlackRoute round-trip across all three shapes + lax-parse
  behaviour.

Lint (ruff) + mypy clean; 210 channel-focused tests pass.

* chore(channels): address PR #382 review-bot feedback

Three line-level findings from github-code-quality on the backfill
review PR.  Copilot had no line-level comments.

- _sse.py:132 — the `except httpx.HTTPStatusError: pass` branch was
  flagged as an empty except.  The original status was already logged
  at WARNING inside the try block (we re-raise ourselves after
  logging), so the handler has real intent.  Added a debug log of the
  exception text + a comment explaining the control flow, so the
  empty-except lint stops firing and the next reader sees why we
  fall through to backoff.
- discord/bot.py:430, cli.py:354, slack/bot.py:1127 — `await task`
  inside `contextlib.suppress` was flagged as "statement has no
  effect".  It's a false positive (await is an effect) and the
  alternative try/except/pass triggers ruff SIM105.  Kept the
  contextlib.suppress pattern and added an explanatory comment above
  each call so the intent (await CancelledError propagation before
  state cleanup) is obvious; will reply on the PR thread noting the
  false positive.

No behavior change.  Lint + mypy clean; 210 channel tests pass.
2026-04-18 05:49:54 -07:00
Patrick Buckley bd6670d748 feat(coordinator): phase 6 — polish, observability, active-coords via SSE, frontend cleanup (#381)
* feat(coordinator): phase 6 — polish, observability, active-coords via SSE, frontend cleanup

Squashed from two working commits:
  1. phase-6 backend polish + active-coords SSE
  2. phase-6 frontend cleanup (legacy chat-view classes + designer nits)

Both tier-A/B observability items and tier-C frontend consolidation
ship together — the shared-vocabulary migration touches surfaces the
backend polish already had its hands in, so one combined commit keeps
the diff reviewable as a coherent phase.

Observability
-------------

- **Coordinator-side wait dashboard** — `_exec_wait_for_workstream`
  emits `wait_started` / `wait_progress` / `wait_ended` SSE events
  via a new `progress_callback` hook on
  `CoordinatorClient.wait_for_workstream`; coordinator.js renders a
  "⧗ waiting · N ws · Ts" header indicator keyed by call_id so
  overlapping waits coexist.  Progress throttled to emit only on
  snapshot-diff or 5s heartbeat; full results dict attached only on
  transitions so a 600s wait doesn't flood SSE listener queues.
  Indicator only attaches when a proper header host exists (no
  floating document.body fallback) and is cleared on SSE reconnect
  so a dropped `wait_ended` can't pin the badge.
- **`cancel_workstream` forensics** — `server.cancel_generation`
  captures `ui._pending_approval` tool names +
  `session._queued_messages` count / preview before invoking
  `session.cancel`, returning the snapshot as `dropped`; routing
  proxy passes it through to the tool result.  Preview runs through
  `redact_credentials` before the 120-char truncate so pasted
  secrets / connection strings don't land verbatim in the
  coordinator's conversation history.
- **Per-coordinator metrics** — `GET /v1/api/coordinator/{ws_id}/metrics`
  returns `spawns_total` / `spawns_last_hour` / `child_state_counts`
  / `judge_fallback_rate` (substring match on verdict.tier) plus
  zero placeholders for wait_* pending dedicated instrumentation.
  Derived from new `storage.count_workstreams_by_state` +
  `count_workstreams_since` aggregate helpers — no 10k-row
  hydrated-select to compute a histogram.  Ownership 404-mask
  matches `coordinator_detail`.
- **Coordinator skill in inspect** — `CoordinatorManager.create`
  resolves `skill` → `template_id` / `applied_version` via
  `get_skill_by_name` + new `storage.count_skill_versions`
  (replacing the SELECT-all-for-COUNT anti-pattern) and persists
  them on the workstreams row.  `/new` handler dispatches via
  `asyncio.to_thread` so blocking storage calls don't stall the
  event loop.
- **`wait_for_workstream(since=…)`** — optional prior-snapshot
  hint; when supplied, the wait loop diffs each polled ws_id that
  IS in `since_map` and exits on any change, independent of mode.
  ws_ids absent from `since_map` fall through to the normal mode
  condition — a disjoint since dict no longer silently exits the
  wait on tick one.
- **`task_list.child_ws_id` referential cleanup** —
  `CoordinatorClient.cleanup_dead_task_child_refs(ws_id)` holds the
  same per-ws `_task_lock` as `task_list_*` so a close racing a
  task_list write can't lose the mutation.  `CoordinatorManager.close`
  delegates.  Final save-failure logs at `warning` instead of
  `debug`.

Home view live-updates
----------------------

- **Active-coordinators via SSE** instead of a 5s poll —
  `ClusterCollector.ensure_console_pseudo_node` +
  `emit_console_ws_created / _closed / _state / _rename` plumbing;
  `CoordinatorManager.create / open / close / eviction` +
  `ConsoleCoordinatorUI.on_state_change / on_rename` all fan out
  through the collector.  The pseudo-node is exempt from the
  discovery-loop eviction; rehydrate-path eviction now also emits
  `console_ws_closed` for the evicted row so other tabs drop it
  live.  `app.js` reads coordinators from
  `clusterState.nodes["console"]`; poller + back-compat shims
  deleted (9 call sites).  Overview / nodes list skip the
  pseudo-node so it doesn't inflate cluster totals.  Tenant-filtering
  preserved by excluding the pseudo-node from
  `collector.get_workstreams` so `/v1/api/cluster/workstreams` still
  uses the existing tenant-filtered `_coordinator_rows` path.
  `CoordinatorManager.NODE_ID` bound from
  `ClusterCollector.CONSOLE_PSEUDO_NODE_ID` so the two literals
  can't drift.

Frontend perf
-------------

- **Bulk cluster-ws live endpoint** — `GET /v1/api/cluster/ws/live?ids=`
  returns `{results, denied, truncated}` (cap 50); coordinator.js
  batches visible-row live-badge fetches into one bulk request per
  ~250ms window (replaces per-row /detail polling).  Ownership
  check routes through the empty-string-safe pattern (non-admin
  with empty `caller_uid` doesn't match empty-owner rows).

Legacy chat-view class cleanup
------------------------------

- Drop the `.msg` / `.msg-user` / `.msg-assistant` / `.msg-tool` /
  `.msg-error` / `.msg-info` / `.approval-block` / `.approval-tool`
  / `.approval-btn` / `.approval-badge` / `.approval-prompt` /
  `.approval-feedback-input` / `.approval-actions` / `.pane-input` /
  `.pane-input-area` / `.pane-input-row` / `.pane-attach` /
  `.pane-attach-chip` / `.coord-msg` / `.coord-body` / `role-*` /
  `btn-approve` / `btn-deny` / `btn-always` / `verdict-glow-*`
  legacy dual-class names left over from the phase-4 migration.
  Every JS className concatenation + querySelector + CSS selector
  now uses the `ts-*` vocabulary from `shared_static/chat.css` (and
  `ui/static/style.css` where the interactive-page extensions
  live).  Feature-specific class names that don't map to `ts-*`
  stay — `msg-queued` / `msg-editing` / `msg-actions` / `msg-edit-*`
  / `msg-user-attach*` / `msg-user-text` / `queued-badge` /
  `queued-dismiss` / `tool-name` / `tool-cmd` / `tool-diff` /
  `tool-header` / `tool-preview`.

Designer nits
-------------

- **`.ui-btn--icon:focus-visible`** — new rule matching `.ui-btn`'s
  `outline: 2px solid var(--accent); outline-offset: 1px` so the
  compact icon variant gets the accent ring instead of the
  browser-default outline.
- **Dropped speculative 701-880px composer wrap rule** — the flex
  math at ≥701px fits comfortably in every desktop viewport, so the
  mid-zone break rule was forcing a 2-line layout where the browser
  wouldn't have wrapped naturally.  The existing `<700px` full-stack
  covers the original wrap observation.
- **`.verdict-badge` border-top** + **`.ch-row.highlight`
  prefers-reduced-motion** — confirmed already in main; no
  additional code change needed for phase 6.

Follow-up designer-review findings
----------------------------------

- Dropped `border-top` from `.ts-approval-badge` + `.ts-approval-body`
  (chat.css's max-content width / flex-gap made them read as
  truncated / floating lines).
- `var(--muted)` → `var(--fg-dim)` on denied tool names (undefined
  token was silently failing).
- Dropped 3 dead `.ts-approval-badge.badge-*` rules + duplicated
  `.ts-approval-btn:focus-visible` + dead `.reasoning` CSS rule +
  `contains("reasoning")` JS guard.
- Dropped `tool-row` / `approval-header` / `btn-row` / `label` dead
  legacy classes in the coordinator.
- Added `:focus-visible` to `.ch-row a.ws-link` + `.task-row` so
  keyboard users get the accent ring on sidebar rows.

Cleanups
--------

- `_WAIT_REAL_TERMINAL_STATES` / `_WAIT_TERMINAL_STATES` /
  `_WAIT_MAX_*` / `_WAIT_POLL_INTERVAL` hoisted to module level on
  `coordinator_client` so `session.py` no longer reads a class
  internal; ClassVar aliases kept for back-compat.
- `ConsoleCoordinatorUI` state/rename observers typed as
  `Callable[[str], None] | None` instead of `Any`.
- SSE error renderer verified end-to-end (coordinator.js already
  handles `case "error"` → `appendText`; no code change).

Tests
-----

- 26 new test cases: 11 for `_diff_since` + `cleanup_dead_task_child_refs`,
  15 for `cluster_ws_live_bulk` + `coordinator_metrics`.  Full suite
  4345 passing (4319 base + 26 phase-6).

Gate: ruff + mypy + pytest -m "not live" (4345 passed) all clean.

* fix(coordinator): address PR #381 review feedback

Copilot comments:

- Cross-tenant aggregate leak in coordinator_metrics — the new
  count_workstreams_by_state / count_workstreams_since aggregates
  took parent_ws_id but not user_id, so a non-admin caller could
  observe drifted / forged child rows that share parent_ws_id with
  their coord but whose user_id drifted to another tenant.  The
  404-mask on coord ownership (_resolve_coordinator_or_404) is the
  primary defense; this is defense-in-depth inside the aggregate
  queries.  Pass filter_user_id (None for admin, caller_uid for
  non-admin) — matches coordinator_children's tenant-push-into-SQL
  pattern.

- wait_for_workstream(since=…) docstring + tool schema were stale —
  claimed "A missing entry counts as changed on first observation"
  but the implementation ignores ws_ids absent from since_map to
  prevent a disjoint since dict from silently exiting on tick one.
  Rewrote both doc sites to match the actual semantics: only ws_ids
  present in `since` participate in the diff-exit check; others fall
  back to the normal mode-based completion condition.

- WAIT_TERMINAL_STATES comment drift — the comment claimed it was
  "used by the resolved-count summary" but the summary counts only
  WAIT_REAL_TERMINAL_STATES (denied is a rejection, not a
  resolution).  Rewrote the comment to describe the real usage:
  mode='any' pure-denied short-circuit + mode='all' settle check.

github-code-quality (CodeQL):

- coordinator.js — dropped the dead typeof _renderWaitIndicator
  guard + the typeof activeWaits guard around the reconnect clear.
  Both symbols are defined in the same IIFE; the onopen handler
  fires strictly AFTER IIFE execution finishes, so the guards
  always evaluated to true.  Removing the dead branching also
  removes a CodeQL nit.

- Protocol-method `...` statements — the bot flagged the three new
  methods (count_workstreams_by_state / count_workstreams_since /
  count_skill_versions) with "statement has no effect".  Left as
  `...` to match the file's universal convention (216 `...` bodies
  / 0 `pass` bodies pre-change); swapping just the new methods to
  `pass` would introduce inconsistency with every other Protocol
  method.  Resolved as non-actionable.

Test: new test_metrics_tenant_filter_excludes_forged_cross_tenant_child
covering the aggregate-query tenant filter with both a legitimate
alice child and a forged bob child sharing parent_ws_id.  Non-admin
alice sees 1; admin sees 2.

Gate: ruff + mypy + pytest -m "not live" (4346 passed) all clean.
2026-04-18 03:49:56 -07:00
Patrick Buckley 334edbd580 fix(server,console): kind filter on saved-workstreams + closed coords on landing (#380)
* fix(server,console): kind filter on saved-workstreams + closed coords on landing

Two independent bugs folded into one hotfix:

1. Coordinators leaking into the interactive UI's "saved workstreams"
   sidebar.  ``list_workstreams_with_history`` (SQLite + postgres) was
   kind-agnostic — every coordinator row with conversation history came
   back alongside interactive rows, and ``list_saved_workstreams``
   serialized them uniformly with no kind field so the interactive UI
   rendered coordinators as regular interactive entries.

   Fix: add optional ``kind: WorkstreamKind | str | None = None`` kwarg
   on ``list_workstreams_with_history`` (storage protocol + both
   backends + the ``turnstone.core.memory`` helper).  Pass
   ``kind=WorkstreamKind.INTERACTIVE`` from the /v1/api/workstreams/saved
   handler so the interactive surface only sees interactive rows.
   Default ``None`` preserves legacy all-kinds behaviour for any
   other caller that wants both.

2. Closed coordinators vanish from the console landing page.
   ``_coordinator_rows`` in console/server.py built dashboard rows
   exclusively from the in-memory ``CoordinatorManager`` registry,
   which pops rows on ``close()``.  The persisted storage row stays
   (state='closed') but never reached the landing-page poller at
   /v1/api/cluster/workstreams?node=console.

   Fix: two-lane merge in ``_coordinator_rows``.  The in-memory lane
   (manager) stays authoritative for live session state (model /
   model_alias / current state / tokens).  A new persisted lane queries
   ``storage.list_workstreams(kind=COORDINATOR, user_id=uid, limit=200)``
   and appends rows NOT already in the in-memory set — surfacing
   closed / error / deleted coordinators so the operator can still
   see them on the landing page.  Ownership semantics unchanged —
   non-admin callers only see their own tenant, admin-bypass via
   admin.users/admin.roles honored on both lanes, empty-string
   defense-in-depth matches _check_row_owner_or_404.

Tests:
- tests/test_storage_sqlite.py — two new tests: kind filter excludes
  coordinators from the history list; string form of kind accepted
  (matches the memory.py forwarding shape).
- tests/test_coordinator_endpoints.py — four new tests:
  - closed coordinators from storage surface alongside active ones.
  - in-memory row wins on ws_id dedup (live state authoritative).
  - persisted rows respect tenant filter (non-admin, admin bypass).
  - orphan rows (empty user_id) never leak to empty-sub callers.

Gate: ruff + mypy + pytest -m "not live" (4315 passed) all clean.

* fix(server,console): address Copilot review on PR #380

Three review comments folded in:

1. Tenancy leak in /v1/api/workstreams/saved — the handler called
   list_workstreams_with_history without a user_id filter, so any
   authenticated user could see every other user's saved workstream
   aliases / titles / names.  Fix:

   - Add ``user_id: str | None = None`` kwarg to
     list_workstreams_with_history on the protocol + both backends
     (SQLite + postgres).  Pushes the filter into SQL.
   - memory.py helper forwards the kwarg.
   - /v1/api/workstreams/saved reads ``_auth_scopes(request)``: a
     service-scoped caller gets cluster-wide visibility (None), a
     non-service caller with a blank ``sub`` returns an empty list,
     otherwise the SQL filter is scoped to the caller's uid.  Matches
     the _visible_workstreams pattern used on /workstreams and
     /dashboard.

2. Loose type annotation on the memory.py helper — ``kind: Any``
   tightened to ``WorkstreamKind | str | None`` so mypy catches
   invalid callers.  WorkstreamKind was already imported in the
   module.

3. Brittle positional indexing in _coordinator_rows persisted-rows
   lane — ``row[10]`` for user_id encoded a column offset that would
   silently corrupt the projection on any future SELECT reorder.
   Drop the test-double fallback entirely; the storage-protocol
   contract already requires SQLAlchemy Row with _mapping, and every
   real caller (SQLite + postgres) provides it.

Tests:
- test_server_authz.py TestSavedWorkstreamsTenantScoping — four new
  regression tests covering: non-service caller sees only own rows,
  service scope sees cluster-wide, blank-sub non-service returns
  empty, and coordinator rows excluded even for service callers.

Gate: ruff + mypy + pytest -m "not live" (4319 passed) all clean.
2026-04-18 03:10:19 -07:00
Patrick Buckley c17eddbbd8 fix(console): service scope on collector token + surface upstream 4xx (#379)
* fix(console): service scope on collector token + surface upstream 4xx

CRITICAL: the console's ClusterCollector ServiceTokenManager was
configured with only frozenset({"read"}) scope, but every upstream
node's /v1/api/events/global hard-gates on "service" scope (added in
PR #375 for cross-tenant authz hardening).  Every console→upstream
SSE connect 403'd, the collector never populated node state, and the
failure was silent — node health, idle workstreams, and interactive-
kind workstream rows all disappeared from the console dashboard with
no user-visible error.  The only surface was a log.debug line in the
collector's _node_sse_task that operators had to opt into via DEBUG
logging or browser DevTools.

Fix:

- Add "service" to the collector_token_mgr scopes
  (turnstone/console/server.py).  Matches the proxy_token_mgr (which
  already has it) and the existing cli / admin / channel-gateway
  service tokens.  Restores /v1/api/events/global SSE subscription
  and /v1/api/dashboard visibility (which silently tenant-filters
  non-service callers to zero rows).

- Upgrade the 4xx path in _node_sse_task to log.warning with the
  status code + 200-char body preview, so configuration-level
  failures (scope misconfig, JWT secret mismatch, expired token)
  show up in operator logs instead of being masked by the generic
  except-block debug line.  Keep transient network errors
  (CancelledError, ConnectError) at debug so the log doesn't flood
  during brief node restarts.

- Add reachable_reason field to NodeSnapshot + surface via
  get_nodes / get_node_detail / get_snapshot (and the browser's
  buildNodeInfoFromSnapshot).  Operators now see the failure cause
  on the cluster node list without tailing the log.  Cleared on
  successful reconnect in _apply_snapshot.

- Test coverage: test_server_authz.py TestGlobalEventsServiceGate
  gains a positive-path test asserting that a token with exactly
  the collector's scope set ({"read", "service"}) is accepted by
  /v1/api/events/global.  Locks in the scope contract so any future
  rename breaks the test before it breaks the dashboard.

Gate: ruff + mypy + pytest -m "not live" (4309 passed) all clean.

* fix(console): address Copilot review on PR #379

Two review comments folded in:

- collector.py — bounded body read for 4xx SSE error previews.  The
  prior ``await source.response.aread()`` buffered the entire
  upstream error body into memory just to log a 200-char preview; a
  malicious / oversized upstream response (HTML error page, proxy-
  generated body) could have forced the collector to download an
  arbitrary amount of bytes.  Iterate ``aiter_bytes()`` and stop once
  the preview cap (256 bytes, ~200 chars after UTF-8 decode) is
  satisfied.

- test_server_authz.py — tighten the service-scope positive test.
  The prior ``assert resp.status_code != 403`` could pass on
  unrelated 500s AND left an SSE stream open indefinitely.  Send
  ``?expected_node_id=definitely-wrong-node-id`` so the handler
  passes the scope gate, hits the post-auth node-identity check, and
  returns 409.  Now ``assert resp.status_code == 409`` proves the
  scope contract precisely and terminates the request immediately.

Gate: ruff + mypy + pytest -m "not live" (4309 passed) all clean.
2026-04-18 02:48:57 -07:00
Patrick Buckley 553d73109b feat(coordinator): phase 5 — harness-test polish + wait_for_workstrea… (#378)
* feat(coordinator): phase 5 — harness-test polish + wait_for_workstream + judge fix

Closes the bug list surfaced by the 2026-04-17 coordinator harness test
plus the post-phase-4 wait_for_workstream ask, and folds in three
adjacent cleanups that landed in the same window.  Tightens defense-in-
depth on the model-invoked mutating ops, fixes the LLM judge silent
no-op, kills the inspect-poll token burn, and rounds out a handful of
observability / docstring / spec gaps.

The session-factory pre-resolve at console/session_factory.py and
server.py was rewriting `judge.model` from an alias (e.g. `judge-mini`)
to the resolved underlying id (e.g. `gpt-5-mini`).  IntentJudge then
checked `model_registry.has_alias(config.model)`, found nothing, and
fell back to the SESSION's provider/client with that bare model id —
silent `llm_fallback / "did not return a verdict"` whenever the
coordinator and judge alias resolved to different providers.

Pass the alias through unchanged; IntentJudge's existing alias-
resolution path picks up the matching client + provider.  Validate
the alias exists so an obvious typo still surfaces, but don't replace
the model field.

Regression: `test_alias_uses_registry_provider_not_session_provider`
constructs an alias whose provider differs from the session's and
asserts the judge picks up the alias's provider/client/model;
`test_coordinator_tool_call_returns_llm_verdict_not_fallback` asserts
the verdict tier is `llm` (not `llm_fallback`) on the happy path.

New `cancel_workstream` tool (approval required, primary_key=ws_id) —
cancels in-flight generation, unblocks any pending approval / plan,
moves the child to idle, leaves the row in storage so a fresh
send_to_workstream lands cleanly.  Re-uses the existing
`/v1/api/route/cancel` route + `route.cancel` audit namespace; no
new server endpoint.

`CoordinatorClient.cancel/close_workstream/delete/send` now enforce
a tenant guard inline (`_is_own_subtree`) — only the coordinator
itself or one of its own children is targetable.  Foreign ids return
the same 404-shape inspect/wait_for_workstream use, so the model
can't distinguish foreign from missing (no existence oracle).
Defense-in-depth — the upstream node enforcement is the perimeter,
this is the second line.

`list_workstreams` advertised `state="deleted"` and an
`include_closed=true` that surfaced deleted rows.  Hard-deletes
cascade the workstream + conversation rows out of storage, so
deleted is unreachable in normal operation.  Doc-only fix; the
synthetic-test path that registers `state="deleted"` rows still
works (terminal-state filter still excludes them via
`_terminal_states = {"closed", "deleted"}` in list_children).

Documented that the 120s service-registry heartbeat window means a
node returned by list_nodes can drop out before a follow-up
`spawn_workstream(target_node=…)` lands — the spawn fails with "No
available node for routing" rather than falling back.  Two-line
clarification on each tool.  No code change (a code fallback is a
bigger discussion deferred to 1.6).

`close_workstream` accepts `reason`; the upstream server handler now
persists it to `workstream_config.close_reason` (capped at 512 BYTES,
sliced on UTF-8 not code points so a CJK / emoji-heavy payload can't
4× the documented budget).  `CoordinatorClient.inspect()` reads it
and surfaces as `close_reason` in the result dict — only for
terminal-state children (closed/error/deleted) so the live-child hot
path doesn't pay a per-inspect DB round-trip.

Tests: server-side persistence covers success / no-reason /
length-cap / non-string / storage-failure / multi-byte-utf8 paths;
client-side surface covers terminal vs. live workstreams.

For idle children whose node-dashboard live counter is 0 (the live
block only surfaces in-flight token counters), fall back to
`SUM(prompt_tokens + completion_tokens)` from `usage_events` so the
inspect output reflects cumulative spend.

New `storage.sum_workstream_tokens(ws_id) -> int` on the protocol +
both backends.  The fallback is folded INTO `_fetch_cluster_live` so
the merged live block (with persisted total applied) is what gets
cached — back-to-back inspects of an idle child amortize through
the existing 2s LRU cache instead of each firing a fresh aggregation.

`CoordinatorClient.list_skills()` now projects `allowed_tools` per
skill — capped at 20 with a `+N more` sentinel so a skill that
whitelists a wide MCP surface doesn't bloat the per-row payload.
Reads the existing `prompt_templates.allowed_tools` column; no
storage change.  Coordinators no longer have to guess what tools a
skill brings.

`route_create` now sets `routing_strategy: "hash_ring" | "target_node"
| "resume"` on the spawn response so the coordinator's spawn
response (and the `spawn_workstream` tool output) carries why a
given node was chosen.  3 lines + 3 covering tests in
test_console_routing_proxy.py.

New coordinator tool `wait_for_workstream(ws_ids, timeout=60,
mode='any'|'all')` that absorbs the wait into a single tool call —
the model sees one call + one result regardless of how long the
children take.  Kills the busy-poll inspect loop that burned 20+
turns on a 3-child fan-out.

Storage-poll loop with batched primitives —
`get_workstreams_batch` + `sum_workstream_tokens_batch` issue exactly
two storage calls per tick regardless of N.  At the cap (32 ws_ids /
600s / 0.5s tick) that's ~2400 round-trips for a full wait, down
from ~38k under the naive per-id shape.

Validation single-source-of-truth: the client owns mode whitelist,
ws_ids dedup + cap, timeout coerce + clamp.  The session preparer
is a thin pass-through that builds the header + dispatches; bad
input surfaces at exec time as a tool error via `result.get("error")`.

Tenant-isolation collapse: missing-row and cross-tenant cases both
return `state="denied"` so wait can't be used as an existence oracle
(matches the 404-mask contract `inspect` uses).

Prompt-side: tools_coordinator.md adds a `wait_for_workstream`
pattern + an explicit "PREFER wait_for_workstream OVER a loop of
inspect_workstream" line in the workflow-shape section.

Replaces the quote-bracketed substring LIKE/ILIKE pattern with proper
JSON-array containment.  The previous shape effectively did
`LOWER(tags) LIKE '%"<lower-tag>"%'`, which broke for tag values
containing `"` (the JSON encoder escapes it to `\"` and the literal-
substring search misses), `\` (encoded as `\\`), or non-ASCII
characters that the encoder rendered as `\uXXXX`.  Also exposed a
small spoofing surface — `tags=["foo\","bar"]` would have matched a
query for `bar`.  Real-world tag values are alphanumeric+dash today
so it hadn't fired in production, but the fix is small.

- SQLite: `EXISTS (SELECT 1 FROM json_each(prompt_templates.tags)
  WHERE lower(value) = lower(:tag))` (JSON1 extension; SQLite 3.38+).
- PostgreSQL: `EXISTS (SELECT 1 FROM jsonb_array_elements_text(
  prompt_templates.tags::jsonb) AS jat(elem) WHERE lower(jat.elem) =
  lower(:tag))`.

Three new tests prove the substring pattern was broken for
quoted / backslash / unicode tag values; the existing case-fold +
wildcard tests continue to pin the contract.

Phase 1 added the coordinator workstream API; phase 2 added only
`/open` to the OpenAPI catalog and missed every other coordinator
endpoint plus phase 3's `/children`, `/tasks`, and the
`/cluster/ws/{ws_id}/detail` aggregator.  SDK consumers + operators
browsing `/docs` couldn't discover the surface.  Doc-only addition:
12 endpoints + 9 new Pydantic models, all under the `Coordinator`
OpenAPI tag so /docs groups them together.

Sidebar re-fetches `GET /tasks` on every `task_list` `tool_result`
SSE event.  A model that runs `add → list` (or any back-to-back
mutation pair) double-fetches the same envelope.  Coalesced into
one fetch per 150ms window via a new `loadTasksDebounced` wrapper;
direct UI actions (refresh button, page load) keep calling
`loadTasks` directly so user clicks aren't delayed.

- `ruff check turnstone tests` — clean
- `mypy turnstone` — clean (157 source files)
- `pytest -m "not live"` — 4284 passed, 3 deselected (was 4226 on
  main; +58 new tests across coordinator client, tools, judge,
  storage, console routing proxy, server close-handler,
  storage_skills_filtered, OpenAPI catalog, server close-reason
  persistence)
- New tools added: 2 (cancel_workstream, wait_for_workstream) —
  TOOLS count 28 → 30; coordinator subset 9 → 11; auto_approve adds
  wait_for_workstream; primary_key adds cancel_workstream
- New OpenAPI endpoints: 12 (every phase-1/2/3 coordinator route +
  the cluster-inspect aggregator)
- New storage protocol methods: 3 (sum_workstream_tokens,
  sum_workstream_tokens_batch, get_workstreams_batch)

All phase 1 / 2 / 3 / 4 invariants preserved: COORDINATOR_TOOLS /
INTERACTIVE_TOOLS disjoint; coordinator sessions have no MCP surface;
list-style tools return {items, truncated}; route-proxy emits
route.<action> audit on 2xx; 404-mask on ownership failures; tenant
filters pushed into SQL; per-coordinator JWT carries scope context.

* fix(coordinator): address Copilot review on PR #378

Three valid Copilot findings on the wait_for_workstream surface:

1. ``wait_for_workstream.json`` description claimed the tool returns a
   top-level mapping ``ws_id -> {state, tokens, updated}`` plus
   elapsed/complete/mode at the same level, but the actual shape is
   ``{results: {ws_id: {...}}, elapsed, complete, mode}``.  Description
   now matches the implementation.  Also adds ``deleted`` to the
   advertised terminal-state list (it's in ``_WAIT_REAL_TERMINAL_STATES``;
   the doc and runtime now agree).

2. ``CoordinatorClient.wait_for_workstream`` docstring listed
   ``idle / error / closed`` as the real terminal set but the constant
   includes ``deleted``.  Same fix — list ``deleted`` with a parenthetical
   noting it's unreachable in normal operation (hard-delete cascades the
   row).

3. Storage protocol docstring math: ``sum_workstream_tokens_batch``
   claimed "from ~38k to ~1200" round-trips per wait at the cap, but
   ``wait_for_workstream`` issues TWO storage calls per tick
   (``get_workstreams_batch`` + this one), so 1200 ticks × 2 = ~2400.
   Updated to "~2400" with the math spelled out.

Also a clean rebase onto today's main (PR #377 — the rebalancer node_id
snapshot doc — landed since phase 5's last push).  Single conflict in
``inspect_workstream.json`` resolved by keeping both notes (rebalancer
node_id binding semantics + the new ``close_reason`` surface from phase
5); ``spawn_workstream.json`` auto-merged.

The github-code-quality bot also flagged three items on
``_protocol.py`` asking to replace ``...`` with ``pass`` in Protocol
method bodies.  Refuted: ``...`` is the canonical PEP 544 idiom for
Protocol method bodies and the rest of the file uses it consistently.
The bot's lint rule misfires for ``Protocol`` classes.

Verification:
- ``ruff check turnstone tests`` clean
- ``mypy turnstone`` clean (158 source files)
- ``pytest -m "not live"`` — 4308 passed, 3 deselected (no test count
  change; pure doc/comment edits)
2026-04-17 23:56:10 -07:00
Patrick Buckley b0a040c8fa docs(tools): clarify node_id snapshot vs current-binding semantics (#377)
Phase 3 fixed spawn_workstream's response to return the storage-
authoritative node_id at spawn time, but neither tool description
mentioned that the cluster rebalancer can migrate the workstream to
a different node afterwards.  A coordinator that cached the
spawn-time node_id for a long-running callback would silently dispatch
to a node that no longer owns the workstream.

- spawn_workstream: ``node_id`` is a POINT-IN-TIME snapshot at spawn;
  re-read with inspect_workstream when you need the current binding.
- inspect_workstream: ``node_id`` is the CURRENT (storage-authoritative)
  binding; reflects any rebalancer migration that happened since spawn.

Pure description edit — no schema or runtime change.
2026-04-17 23:25:45 -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 37ed6bbf5b feat(core): WorkstreamKind enum + list_workstreams user_id filter (#374)
Foundation PR for the multi-stage-review follow-up.  Introduces a
single source of truth for workstream kind values and pushes tenant
scoping into the storage protocol so list callers can't forget to
filter client-side.

- WorkstreamKind(StrEnum) replaces bare "interactive" / "coordinator"
  literals across 17 production modules.  Strict mypy narrows every
  internal call site; raw strings still work at wide boundaries
  (HTTP body, DB row) via WorkstreamKind(raw) parse at the edge.
- StorageBackend.list_workstreams(..., user_id=None) adds a SQL-level
  WHERE user_id = :user_id gate on both sqlite and postgres impls.
  Memory wrapper forwards the new filters.
- register_workstream now validates kind at the storage edge so SDK /
  restore / internal callers can't silently corrupt the NOT NULL
  column with empty / mis-cased / unknown values.
- WebUI.__init__ normalizes empty-string parent_ws_id to None, matching
  the storage-edge and WorkstreamManager invariants.
- POST /v1/api/workstreams/new parses body["kind"] through the enum
  and returns 400 on unknown kinds instead of silent coercion.

Absorbs bug-1, bug-2, bug-4/q-6, q-1, q-8, and partial q-2 (wrapper
signature forwards the new filters; full deletion of the unused
wrapper stays in the cleanup PR).
2026-04-17 22:10:57 -07:00
Patrick Buckley d3f6514e11 feat(ui): phase 4 — chat-UX unification + coordinator-first console landing (#373)
* feat(ui): phase 4 — chat-UX unification + coordinator-first console landing

Phase 4 unifies the three turnstone UIs (server-node chat, console
dashboard, coordinator page) around a shared design-system layer,
promotes coordinator sessions to first-class citizens on the console
landing, and folds the chat-view itself onto a shared vocabulary so
the two chat pages no longer reinvent messages / approvals / composer /
header / sidebar chrome from scratch.

## Shared static consolidation

- turnstone/shared_static/renderer.js — consolidates the two copies
  (ui/static/ + console/static/coordinator/) into one.  Adds
  streamingRender / streamingRenderFinalize helpers with
  requestAnimationFrame coalescing + per-element buffer cache so both
  chat views re-render the streamed markdown smoothly without the
  prior "plain-text → final pop" on the coordinator page and without
  thrashing renderMarkdown + DOM replacement faster than the paint
  cycle.  renderMarkdown stays the trust boundary for innerHTML
  assignment (escapeHtml internal); postRenderMarkdown (syntax
  highlighting, mermaid, KaTeX) is deferred to finalize.
- turnstone/shared_static/ui-base.css — flat form-control + button +
  state-glyph + pill + panel vocabulary on top of base.css.  Sizes in
  px to match the 11/12/13px scale used elsewhere.  Namespace rubric
  documented inline (.ui-* shared controls; .dash-* dashboard legacy;
  .ts-* chat vocabulary; page-local stays unprefixed).
- turnstone/shared_static/chat.css (new) — chat-view component
  vocabulary: .ts-msg (user / assistant / reasoning / tool / error /
  info), .ts-msg-actions floating toolbar, .ts-approval (inline +
  batch layout hooks sharing a visual language), .ts-verdict-badge,
  .ts-composer shell, .ts-header shell, .ts-sidebar shell.  Mobile +
  reduced-motion covered.

## Interactive server UI migration

- turnstone/ui/static/app.js — dual-class adoption of .ts-msg + .ts-
  approval + .ts-composer + .ts-msg-actions alongside existing class
  names so feature-specific rules (.msg-user-text, .msg-queued,
  .msg-editing, .msg-action-btn toolbar, attachment chips, verdict
  details, media embeds, plan inline) keep working while the shared
  chat.css baseline takes over padding / border / typography.
- Left-aligned user messages: .msg-user loses align-self: flex-end
  and .msg-assistant loses align-self: flex-start.  Both roles now
  render as single-column blocks distinguished by left-border colour
  (amber for user, neutral for assistant, dashed for reasoning,
  mono + code-bg for tool, red for error) per the locked design.
- style.css trimmed: .msg / .msg-info / .msg-error baseline rules
  dropped (chat.css provides); all other feature rules intact.
- index.html links /shared/chat.css and tags the header with
  .ts-header + .ts-header-title.

## Coordinator page migration

- coordinator.js appendMsg drops the visible .role-label <div> per
  the hybrid no-labels design, preserving the role text on
  data-ts-role + aria-label so screen readers and SSE dedup-by-call-
  id continue to see meaningful labels.  Adds .ts-msg + .ts-msg--*
  variants + .ts-msg-body onto the existing .coord-msg / .coord-body
  elements.
- coordinator/index.html adopts .ts-header, .ts-header-title,
  .ts-header-spacer, .ts-header-status on the header; .ts-approval +
  .ts-approval--batch on the pinned bar with .ts-approval-btn
  variants on the buttons; .ts-composer + .ts-composer-input +
  .ts-composer-send on the composer; .ts-sidebar + .ts-sidebar-
  section + .ts-sidebar-section-heading on the children + tasks
  sidebar.  Inline <style> pared from ~300 to ~130 lines — only
  genuinely coordinator-specific layout (flex wiring, sidebar list
  rows, mobile accordion breakpoint) remains.
- Nits fixed along the way: .ch-row .glyph-thinking recoloured cyan
  to match the shared .ui-glyph vocabulary; .task-row .status-done
  lost its 0.7 opacity (colour already signals done; opacity
  reduced contrast for no gain).

## Console landing + admin panel redesign (phase 4 scope-expansion)

Replaces the node-list-first console landing with a coordinator-first
layout on a new #view-home pane:

- #coord-composer-panel — persistent "Start a new coordinator task"
  composer (textarea + optional name + skill dropdown + submit).
  Permission-gated on admin.coordinator (same rule the +coordinator
  header button uses).  Pre-probes GET /v1/api/coordinator on init
  and after login (bug-1 fix) so a 503 (no coordinator.model_alias
  resolvable) surfaces as a remediation banner linking to Admin →
  Models instead of failing on submit.  Probe gates on r.ok instead
  of r.status !== 503 (bug-2 fix) so auth/permission errors don't
  incorrectly flip the banner to ready.  Composer and modal share a
  _createCoordinator helper (q-1 fix) — POST + redirect + error-
  handling tail is not forked.
- #active-coordinators — SSE-driven list of kind=="coordinator"
  workstreams, rendered through the shared _renderWsRow helper so
  state glyphs + child-count badges match the existing tree view.
- #cluster-summary-compact — one-line aggregate.  Clicking expands
  into the legacy #view-overview via showOverview() so deep-link
  callers of ?view=overview / ?view=node / ?view=filtered keep
  working unchanged.

View switching consolidated into a _setLandingView helper so every
show* / drillDown* function toggles the four landing panes through
one call path.  Default currentView flipped from "overview" to
"home"; popstate + init history.replaceState land on {view: "home"}.

patchClusterState preserves kind / parent_ws_id / user_id on
ws_created events (phase 3 invariant) so the active-coordinators
list picks up new coordinators immediately without a snapshot
refetch.

Header H1 is now a home link so operators have a single-click path
back to the coordinator landing from any drill-down / admin view.

## Design polish (review pipeline fixes)

- .home-panel-title dropped from 13px/accent to 11px/fg-dim so it
  sits in the same heading tier as .ui-section-heading / .dash-
  header-title / .home-section-title instead of outweighing them
  (dsn-3).
- .home-composer-banner recoloured from amber-on-amber-glow to
  bg-surface + 1px yellow border + fg-bright text + accent link
  with thicker underline (dsn-1).
- .ui-pill--done dropped the 0.75 opacity — colour signals done,
  opacity reduced contrast for no gain (dsn-6).
- .ui-heading fleshed out with --sm/--md/--lg tiers so the utility
  actually conveys size (dsn-12).
- ui-base.css size scale moved from rem to px matching 11/12/13px
  (dsn-2).

* fixup: address Copilot feedback on PR #373

- admin.js: drop the stale `#view-overview` display:none mutation in
  showAdmin.  #view-overview is now nested inside #view-home and
  toggled via the `hidden` attribute; inline display:none here would
  stick after returning to home and suppress the cluster-details
  expand.
- app.js: reword the _renderHomeView token-bucket fingerprint comment
  to match the actual `Math.floor(tokens / 100)` bucketing — the
  prior comment said "thousands / sub-thousand drift".
2026-04-17 18:53:14 -07:00
renovate[bot] d056e375ef chore(deps): update dependency typescript to v6.0.3 (#371)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-18 01:36:09 +00:00
Patrick Buckley c397668d21 feat(coordinator): tree-view UI, cluster-wide live inspect, dashboard… (#370)
* feat(coordinator): tree-view UI, cluster-wide live inspect, dashboard grouping — phase 3

Closes out the 1.5 coordinator UX surface: a right-sidebar tree view at
/coordinator/{ws_id} showing spawned children + task list, a new
cluster-wide live inspect endpoint that powers the tree's live badges,
and 2-level dashboard tree grouping that nests spawned children under
their coordinator parent.

## Cluster-wide live `inspect_workstream`

New `GET /v1/api/cluster/ws/{ws_id}/detail` on the console, gated by a
new `admin.cluster.inspect` permission (unassigned to any builtin role;
operators opt in).  Aggregates `storage.get_workstream` with a
short-timeout (2s) HTTP fetch against the owning node's
`/v1/api/dashboard`.  Coordinator-hosted workstreams get their `live`
block from the in-process `CoordinatorManager` instead of a proxy hop.

Response shape `{persisted, live, messages}` — `live: null` on node
unreachability / 5xx / missing-entry with status 200 so the UI can
degrade gracefully without an error state.  Correlation-id masks
unexpected exceptions.  404-masks cross-tenant reads (non-admin
callers see only their own workstreams).

`CoordinatorClient.inspect()` best-effort merges the `live` block onto
its storage snapshot so the model-facing `inspect_workstream` tool
gains a `live` key without any schema change.  Model-facing tool
schema stays identical.

## Tree-view UI

New right sidebar at `/coordinator/{ws_id}` with a 2-level children
tree + the phase-2 task list.

Backend:
- New `GET /v1/api/coordinator/{ws_id}/children` returns
  `{items, truncated}` — identical row shape to the `list_children`
  tool — filtered via `storage.list_workstreams(parent_ws_id=..., kind=None)`.
- New `GET /v1/api/coordinator/{ws_id}/tasks` returns the
  `{version, tasks}` envelope via the shared module-level
  `load_task_envelope` decoder (extracted from `CoordinatorClient`
  so both the tool path and the UI read share corruption semantics).
  Corrupt envelopes return an empty list for UI resilience — the
  `task_list` tool remains the authoritative write + error path.
- `CoordinatorManager` subscribes to the `ClusterCollector`'s
  listener channel from the console lifespan and dispatches filtered
  `child_ws_created / child_ws_state / child_ws_closed / child_ws_rename`
  events onto each coordinator's SSE stream.  Filter authoritative
  on the server via a per-coordinator child-ws_id registry populated
  lazily on `open()` from storage and incrementally on `ws_created`
  events; cleared on `close()` / eviction.  One SSE connection per
  client, no client-side filtering.

Frontend:
- DOM-method-only child-row rendering (no innerHTML of user content).
- State glyph vocabulary (● running / ◐ thinking / ⚠ attention /
  ✗ error / ○ idle) plus text labels — WCAG 1.4.1 carries info in
  both glyph and label.
- Live badges (tokens + pending-approval pip) fetched via
  `/cluster/ws/{ws_id}/detail` with a 5s TTL cache and 250ms debounce
  per child.  One request per state change, not per second.
- SSE child events update in place; renderChildren() re-sorts.
- Mobile (<700px) sidebar collapses to an accordion above the chat
  with a toggle button flipping aria-expanded; a `.highlight` flash
  marks task→child scroll targets; `prefers-reduced-motion` respected.
- Deep-link child rows to `/node/{node_id}/?ws_id=<child>` via
  `<a target="_blank" rel="noopener">` with encodeURIComponent on
  regex-validated ids.

## Dashboard tree grouping

Cluster dashboard rows now group by `parent_ws_id`.  Coordinator rows
(`kind == "coordinator"` or children present) get an expand/collapse
caret (button with `aria-expanded`); collapsed shows "(N children)".
Expanded renders children indented as sibling rows with a left-border
gutter.  Orphaned children (parent missing or closed) render at top
level with a muted "orphan" badge.  Expansion state persisted in
`localStorage` keyed per coordinator ws_id so operator preference
survives reloads.  Coordinator rows deep-link to `/coordinator/{id}`;
node-backed workstreams keep their existing proxy deep-link.

Per-node `ws_created / ws_state / ws_activity` SSE event payloads
gained `parent_ws_id` + `kind` so the collector can propagate them
through its fan-out to browser clients without a second lookup;
`_build_node_snapshot` and `/v1/api/dashboard` rows include the
same.  Coordinators (which don't live on cluster nodes) merge into
`/cluster/workstreams` via a new `_coordinator_rows` helper that
threads them through the collector's `get_workstreams(extra_rows=...)`
parameter — extras share the filter / sort / paginate pipeline with
node-backed rows.

## Tests

- `tests/test_coordinator_endpoints.py` — 19 new cases covering
  children (empty / populated / ownership 404 / admin bypass /
  invalid ws_id / truncation), tasks (empty / round-trip / corrupt /
  ownership), and cluster-inspect (auth gates / 400 / 404 / ownership /
  coordinator self-path / unloaded-live-null / message-limit clamp).
- `tests/test_coordinator_manager.py` — 8 new cases covering registry
  bootstrap on create + open, dispatch for each event type,
  unrelated-parent filtering, shutdown idempotency.
- `tests/test_console.py` — existing `cluster_workstreams` assert
  updated for the new `extra_rows` kwarg.

## Verification

- `ruff check turnstone tests` clean.
- `mypy turnstone` clean.
- `pytest -m "not live"` — 4184 passed, 3 deselected.

* fix(coordinator): race in dispatch + ui_factory kwarg filtering — PR #370 review

Addresses feedback from the GitHub Copilot + code-quality bot review
passes on PR #370.

## Race in _dispatch_child_event ws_created branch

Copilot flagged a TOCTOU where the lock-free read of
``self._active_coords`` (line 912) could see the parent coordinator,
then ``close()`` / eviction pops ``_children[parent]`` + drops the
coord from ``_active_coords`` before we acquire ``_children_lock``,
and then ``setdefault(parent, set())`` resurrects the entry —
leaking the registry key forever and fanning events to a closed UI.

Fix: re-check ``parent in self._active_coords`` inside
``_children_lock``.  The reference swap is still atomic; holding
``_children_lock`` and re-reading the snapshot catches the race
without serializing back through ``self._lock``.

Regression test: create → close → dispatch a ws_created → assert
neither ``_children`` nor ``_active_coords`` regained the entry.

## ui_factory kwarg filtering via inspect.signature

code-quality bot flagged that the previous ``try ui_factory(…, kind=,
parent_ws_id=) except TypeError`` dance fired on every call with
legacy test factories (``lambda wid: WebUI(ws_id=wid)``) — wasteful
and masks real signature mismatches.

Fix: inspect the factory's signature and only pass kwargs it
actually accepts (explicit param name OR ``**kwargs`` absorber).
Keep a conservative ``except TypeError`` fallback for C-callables
and odd signatures ``inspect`` can't introspect.

Copilot also flagged a comment mismatch (the old comment said
"KeyError on **kwargs" — it's ``TypeError``, which is what the code
caught).  The rewritten comment is correct.

## Nit: side-effect in assert

code-quality bot flagged ``assert mgr.close(ws.id)`` in
test_coordinator_manager.py.  Split into two statements.

## Verification

- ``ruff check`` clean.
- ``mypy turnstone`` clean.
- ``pytest -m "not live"`` — 4223 passed, 3 deselected, 0 failed.
2026-04-17 14:38:13 -07:00
Patrick Buckley 8650370790 Feat/coordinator phase2 audit (#369)
* feat(coordinator): audit middleware on routing proxy — phase 2

Adds per-tool-call audit attribution to the multi-node routing proxy
handlers so coordinator → server hops land observable rows in
``audit_events``.  Phase 1 preserved the ``src="coordinator"`` claim
through ``_proxy_auth_headers``'s upstream re-mint; this commit
closes the recording side.  Was the last real security gap from
phase 1 — an enterprise deployment with ``admin.coordinator``
granted got only the three console-side
``coordinator.{create,close,cancel}`` rows; per-tool-call
attribution was missing.

## Action-naming scheme

  route.workstream.create   POST /v1/api/route/workstreams/new
  route.workstream.send     POST /v1/api/route/send
  route.workstream.close    POST /v1/api/route/workstreams/close
  route.workstream.delete   POST /v1/api/route/workstreams/delete
  route.approve             POST /v1/api/route/approve
  route.cancel              POST /v1/api/route/cancel
  route.command             POST /v1/api/route/command
  route.plan                POST /v1/api/route/plan

Action-name conventions documented in ``turnstone/core/audit.py``
module docstring alongside the existing namespaces — the docstring
is now ``<resource>.<verb>`` shaped (non-exhaustive) rather than
trying to enumerate every prefix.

## Recording rules

- ``record_audit()`` fires only on a 2xx upstream response.  4xx/5xx
  are observable via ``_record_route``'s metrics path; doubling the
  audit-events table size for failure rows would dilute signal
  without giving operators much extra value.
- ``detail`` JSON carries ``{src, node_id, coord_ws_id?}`` — ``src``
  lands verbatim from ``auth.token_source`` so non-coordinator
  origins (``"jwt"``, ``"console-proxy"``) also get attribution;
  ``coord_ws_id`` only appears when the inbound JWT carried it.
- Wrapped in ``try/except`` + ``log.debug("route.audit_failed", ...)``
  defence-in-depth.  ``record_audit`` itself is fire-and-forget;
  the outer try guards against a programmer error in the call site.

## Routing-proxy specifics

- ``route_create``: emits at the post-multipart/JSON convergence
  ``if resp.status_code == 200`` block.  Both branches set
  ``audit_ws_id`` correctly — multipart from the query-string ws_id,
  JSON from ``body["ws_id"]`` (post-503-retry) or ``body["resume_ws"]``.
- ``route_proxy``: emits the URL-method-mapped action.  ``ref`` is
  reassigned to ``new_ref`` after a successful 404→cache-refresh
  retry so audit attribution uses the retried node, not the failed
  first node.
- ``route_workstream_delete``: emits on 2xx using the ws_id from
  the request body.
- ``route_attachment_proxy``: out of scope (upstream attachment
  endpoints emit their own ``workstream.attachment.*`` rows;
  auditing here would double-count).

## Tests

16 new tests in ``tests/test_route_proxy_audit.py`` covering:
- Coordinator-origin emission with full detail payload.
- 502 / 400 / 503-retry-final-node-id paths.
- Parametrised method→action mapping for the 6 ``route_proxy`` URLs.
- Plain-JWT origin (no ``coord_ws_id`` in detail).
- Delete handler 2xx + 502.
- Audit-storage exception swallowed (proxied response unchanged).
- ``auth_storage`` absent → no-op (existing route-handler tests
  unaffected).

Verification: ``ruff check`` clean, ``mypy turnstone`` clean
(156 source files), ``pytest -m "not live"`` 4087 passed,
3 deselected (live-backend), 0 failed.

* feat(coordinator): discovery tools and /open parity — list_nodes, list_skills, POST /coordinator/{ws_id}/open

Adds the read-side surface coordinators need to make informed
orchestration decisions plus an explicit rehydration endpoint
matching the server's ``POST /v1/api/workstreams/{ws_id}/open``.

## list_nodes (auto-approved)

``list_nodes(filters={key: value, ...})`` reads ``node_metadata`` via
``storage.filter_nodes_by_metadata`` + ``get_all_node_metadata`` —
one query each, no N+1.  Each row carries its full metadata dict so
the coordinator has both auto keys (``arch`` / ``cpu_count`` /
``fqdn`` / ``hostname`` / ``os`` / ``os_release`` / ``python``;
always present) and operator-supplied user keys (``capability`` /
``region`` / ``tenant`` / ``role``) without a second round-trip.
Tool description enumerates the auto keys explicitly so the model
knows what's always available vs deployment-specific.

Storage stores metadata values as JSON-encoded strings (the write
path in ``server.py`` / ``admin.py`` / ``console/server.py`` all go
through ``json.dumps``).  The client re-encodes filter values
before the stored-text comparison and decodes stored values before
returning them to the model — so ``{"capability": "gpu"}`` is the
natural form the model uses, not ``{"capability": "\"gpu\""}``.
Ints round-trip as ints.

Returns ``{nodes, truncated}``; ``truncated=True`` when the page
was full.

## list_skills (auto-approved)

``list_skills(category?, tag?, scan_status?, enabled_only?, limit?)``
surfaces the skill registry so coordinators can discover worker
profiles.  New storage protocol method ``list_skills_filtered(...)``
on both SQLite and PostgreSQL backends pushes filters into SQL.
``tag`` filter matches against the JSON-array ``tags`` column with
quote-bracketed substring (``%"tag"%``) — quote-safe against
``foo`` vs ``foobar`` collisions on both backends.

Returns ``{skills, truncated}`` with ``name`` / ``category`` /
``tags`` (decoded to list) / ``version`` / ``description`` /
``model`` / ``enabled`` / ``scan_status`` / ``activation`` — the
discovery projection, not the full row.

## POST /v1/api/coordinator/{ws_id}/open

Explicit rehydration endpoint.  Lazy ``GET`` rehydration works for
the UI; this gives SDK callers and operators a way to warm a
coordinator without browsing to it.  Same ownership / 404-on-
mismatch / correlation-id-masked error semantics as
``coordinator_detail``.  Returns ``{ws_id, name, already_loaded?}``.
Registered in ``turnstone/api/console_spec.py`` with a dedicated
``CoordinatorOpenResponse`` Pydantic model so the OpenAPI schema
matches the wire shape.

## Tests

- ``tests/test_storage_skills_filtered.py`` — 8 cases validated on
  BOTH SQLite and PostgreSQL backends (``pytest --storage-backend
  postgresql``).  Covers no-filter ordering, category exact-match,
  tag quote-safety (``"foo"`` matches ``["foo","bar"]`` but not
  ``["foobar"]``), scan_status, enabled_only, limit, AND semantics,
  empty result.
- ``tests/test_coordinator_client.py`` — 11 new cases covering
  node/skill shape decoding, JSON-encoded filter round-trip (the
  ``"gpu"`` vs ``'"gpu"'`` case), int filter encoding, truncation,
  no-match empty, no N+1 (``get_prompt_template`` /
  ``get_node_metadata`` call counts asserted zero).
- ``tests/test_coordinator_tools.py`` — 11 new cases for
  ``_prepare``/``_exec`` dispatch, filter type-drop, limit clamping
  (``limit=0`` falls back to 100, negatives clamp to 1),
  truncation-signal summary.
- ``tests/test_coordinator_endpoints.py`` — 8 new cases for
  ``/open``: ``already_loaded`` on in-memory hit, 404 on ownership
  mismatch, lazy rehydrate on miss, admin bypass, unknown ws_id,
  503 on ``coord_mgr`` unavailable, 500 with correlation-id mask on
  factory failure, 503 passthrough on ``ValueError``.
- ``tests/test_workstream_kind.py`` / ``test_tools_schema.py``
  updated to include ``list_nodes`` and ``list_skills`` in the
  disjoint-namespace regression guard and the tool-count check.

Verification: ``ruff check`` clean, ``mypy turnstone`` clean
(156 source files), ``pytest -m "not live"`` 4122 passed, 3
deselected (live-backend), 0 failed.  Postgres backend storage
tests green (``pytest --storage-backend postgresql
tests/test_storage_skills_filtered.py`` 8 passed).

* feat(coordinator): task_list tool — persistent planning state

Adds a coordinator-only ``task_list`` tool persisted on the
coordinator's own ``workstream_config`` row.  Gives coordinators a
scratch surface for work decomposition that survives restarts so the
UI can render planned-vs-done state once the tree view lands.

## Tool surface

``task_list(action, ...)`` with five actions:

- ``list``     auto-approved read.  Returns ``{tasks, truncated}``;
               truncated=True when the list exceeded the 200-row
               page cap.
- ``add``      needs approval.  ``title`` required; optional
               ``status`` and ``child_ws_id``.  Title clamped at
               200 chars.  Capacity cap at 500 tasks — hitting the
               cap is an explicit signal to prune done/blocked rows.
- ``update``   needs approval.  Mutate by ``task_id``; fields
               ``title`` / ``status`` / ``child_ws_id`` optional.
- ``remove``   needs approval.  Drop by ``task_id``.
- ``reorder``  needs approval.  Pass ``task_ids``; validated as an
               exact permutation of the current set (rejects
               partial, extra, or substituted ids — prevents silent
               task loss).

Status enum: ``pending`` / ``in_progress`` / ``done`` / ``blocked``.
``child_ws_id`` links a task to the child workstream spawned for it
(no enforcement; the coordinator owns the relationship).

## Persistence

Stored as a single JSON-envelope value on ``workstream_config`` —
``{"version": 1, "tasks": [...]}``.  No new table; the kanban v2
work will supersede this row via a format migration keyed on
``version``.  ``_save_task_list`` writes only the ``tasks`` key so
concurrent writers to other ``workstream_config`` keys (e.g. the
admin Settings UI updating ``reasoning_effort``) aren't clobbered
by a read-modify-write on the full row.

## Corrupt-envelope safety

A hand-edited or legacy config row that doesn't parse as the
expected shape logs a warning and returns an empty envelope from
``task_list_get``.  Mutators refuse to overwrite corrupt data —
they detect the sentinel and return a clear error so the operator
can inspect or clear the row rather than losing work silently.

## Concurrency

Per-(ws) ``threading.Lock`` cached on the client.  The worker
thread is single-threaded for tool execs so this is mostly
defence-in-depth against future maintenance-script call sites.
Cache never grows beyond one entry per coordinator session because
the scope guard short-circuits foreign ``ws_id`` before the lock
is acquired.

## Malformed-JSON recovery

``_prepare_tool`` fallback-1 regex-extract allowlist extended with
``action`` / ``status`` / ``task_id`` / ``title`` (alphabetized) so
slightly-malformed ``task_list`` calls get the same
self-correction behaviour as the other coordinator tools.

## Tests

- ``tests/test_coordinator_client.py`` — 15 new cases covering:
  fresh-envelope shape, add/get roundtrip, empty-title + invalid-
  status rejection, 200-char title clamp, update by id + missing
  id, remove semantics, reorder permutation validation (partial +
  extra + wrong id + valid), cross-ws scope violation, corrupt-
  JSON read recovery, corrupt-envelope write refusal (all four
  mutators), 500-task capacity cap, workstream_config key
  preservation across ``_save_task_list``.
- ``tests/test_coordinator_tools.py`` — 12 new cases covering the
  dispatch layer: list auto-approved, each mutating action needs
  approval, unknown-action / missing-required-arg errors, list
  returns tasks, page-cap at 200 with truncated signal, add
  dispatches to client, reorder surfaces permutation error,
  remove-not-found.
- ``tests/test_tools_schema.py`` / ``tests/test_workstream_kind.py``
  extend the tool-count + disjoint-namespace + primary-key
  regression guards with ``task_list``.

Verification: ``ruff check`` clean, ``mypy turnstone`` clean
(156 source files), ``pytest -m "not live"`` 4148 passed,
3 deselected (live-backend), 0 failed.
2026-04-16 23:44:28 -07:00
Patrick Buckley e42add1b77 feat(coordinator): coordinator workstream kind — phase 1 (#368)
* feat(coordinator): coordinator workstream kind — phase 1

Adds a new ``kind="coordinator"`` workstream that runs inside the
``turnstone-console`` process (first ChatSession hosted on the console)
with a dedicated tool set for spawning and driving child workstreams.
Supersedes the external ``turnstone-coordinator`` MCP side-car for new
installs; the extension is marked deprecated in
``examples/mcp-cluster-ops/README.md`` but still works on 1.4-and-earlier
clusters.

Phase 1 ships: the workstream class, 6 lifecycle tools, console hosting,
9 HTTP endpoints, per-user audit attribution, and a one-pane web UI at
``/coordinator/{ws_id}``.  Node/skill discovery tools, task-list tool,
tree-view UI, and routing-proxy audit middleware follow in a later PR.

## Schema

Migration 039 adds ``kind`` / ``parent_ws_id`` columns + indexes to
``workstreams``.  Both SQLite and PostgreSQL backends take the new
kwargs on ``register_workstream``; empty-string ``parent_ws_id``
normalises to ``NULL`` at the storage edge.  PostgreSQL uses
``INSERT ... ON CONFLICT DO NOTHING`` to match SQLite's ``OR IGNORE``
and close a pre-existing SELECT-then-INSERT TOCTOU window.
``list_workstreams`` gains optional ``parent_ws_id`` / ``kind`` filters;
new ``get_workstream(ws_id)`` returns the full row (the existing
``get_workstream_metadata`` stays untouched for back-compat).

## Core session + kind routing

- ``ChatSession.__init__`` accepts ``kind`` / ``parent_ws_id`` /
  ``coord_client``.  On ``kind="coordinator"`` it swaps
  ``_tools = COORDINATOR_TOOLS`` and zeros sub-agent tool lists.
- ``Workstream`` dataclass extended with ``user_id`` / ``kind`` /
  ``parent_ws_id``.  Both ``WorkstreamManager`` and the new
  ``CoordinatorManager`` use the same type — no parallel hierarchy.
- ``_SessionFactory`` Protocol + server / cli factory closures thread
  the new kwargs.  ``POST /v1/api/workstreams/new`` rejects
  ``kind != "interactive"`` with 400; ``POST
  /v1/api/workstreams/{ws_id}/open`` refuses coordinator rows so a
  server node can't accidentally rehydrate one.

## Coordinator tool set

Six tools (``spawn``, ``inspect``, ``send``, ``close``, ``delete``,
``list_workstreams``) with a ``coordinator: true`` metadata flag,
scoped to coordinator-kind sessions only.  ``inspect`` and ``list`` are
auto-approved reads; the four mutators need approval.  ``list`` returns
``{"children": [...], "truncated": bool}`` so the model can detect
post-filter under-fill and paginate.

## CoordinatorClient (in-process, sync)

Mutating ops HTTP-POST to the console's own ``/v1/api/route/*`` on the
local bind URL so every existing middleware (auth, rate-limit) runs.
Read ops hit ``storage.list_workstreams`` / ``get_workstream`` /
``load_messages`` directly — the routing proxy doesn't expose
list/inspect paths.  URL paths are a validated constant table (avoids
an httpx ``base_url``-merge trap).  A new
``/v1/api/route/workstreams/delete`` proxy handler joins the existing
route-proxy endpoints.

## Per-session coordinator JWT

``CoordinatorTokenManager`` mints short-lived JWTs with ``sub=<real
user>`` (attribution preserved), ``src="coordinator"``,
``aud="turnstone-console"``, ``coord_ws_id=<ws>`` custom claim.
``_proxy_auth_headers`` preserves ``src`` + ``coord_ws_id`` across the
upstream re-mint so server-side middleware sees coordinator-origin,
not ``console-proxy``.  ``AuthResult.extra_claims`` carries
non-reserved claims through validate→remint; ``create_jwt``'s
reserved-claim set (now including ``nbf`` / ``jti``) is symmetric with
``validate_jwt``.

## Console hosts the ChatSession

- New ConfigStore settings: ``coordinator.model_alias`` (required),
  ``reasoning_effort``, ``max_active`` (default 5),
  ``session_jwt_ttl_seconds``.
- Console lifespan builds a ``ModelRegistry`` +
  ``CoordinatorManager``.  Missing / unresolvable alias returns **503**
  with remediation text — never 500.
- ``CoordinatorManager``: placeholder-slot reservation under lock,
  rollback on factory failure, per-ws_id rehydration lock to serialise
  concurrent lazy-opens, ``max_active`` enforced via ``close_idle``
  eviction semantics.
- ``ConsoleCoordinatorUI`` is a thin ``SessionUI`` implementation — no
  global broadcast, no per-node metrics, shared
  ``_APPROVAL_WAIT_TIMEOUT`` constant across approval + plan paths.
- No eager startup rehydration: persisted coordinator rows load lazily
  on first ``GET /v1/api/coordinator/{ws_id}``.

## Console coordinator API

Nine endpoints under ``/v1/api/coordinator/*`` gated by ``approve``
scope + new **``admin.coordinator``** permission (added to
``_VALID_PERMISSIONS``; not in any builtin role — operators opt in
explicitly).  Ownership failures return **404, not 403** and use
strict equality so empty-owner rows don't leak across tenants.
Correlation-id masking on every factory-raising path
(``coordinator_create`` + ``coordinator_detail`` lazy rehydrate) — no
stack traces to the client.

## Audit attribution

Three console-side events (``coordinator.create`` / ``.close`` /
``.cancel``) with the real creator's ``user_id`` plus
``detail={coord_ws_id, src="coordinator"}``.  No schema migration
required.  Per-tool-call audit across the routing proxy is deferred
(needs either a ``source`` column on ``audit_events`` or
``record_audit`` calls wired into the route-proxy handlers).

## Web UI (``/coordinator/{ws_id}``)

One-pane chat served by the console.  Reuses ``shared_static``
(``base.css``, ``auth.js``, ``theme.js``, ``toast.js``, ``utils.js``,
``kb.js``) and the server UI's ``renderer.js`` pipeline (KaTeX, Mermaid,
highlight.js already bundled).

- SSE to ``/v1/api/coordinator/{ws_id}/events`` with exponential-
  backoff reconnect; status line carries a leading glyph
  (● / ○ / ⚠) so state isn't conveyed by colour alone.
- Renders content, reasoning (dimmed italic
  ``.role-reasoning``), tool_result, approve_request, intent_verdict,
  output_warning.
- Child ws_id references auto-wrap to
  ``/node/{node_id}/?ws_id={child}`` links — both ids regex-validated
  before interpolation, everything else HTML-escaped.
- Non-modal approval bar (``role="region"``) with a batch header
  ("Approve N tool calls"), initial focus on the approve button,
  buttons disabled during the in-flight POST, red-bordered deny.
  ``aria-live`` flips to ``off`` during streaming.
- "New coordinator" button on the dashboard header — permission-gated
  on the UI side, matching the backend 403.
- Mobile composer capped under ``@media (max-width: 700px)``.

## Tests

~120 new tests across 8 files: workstream-kind storage + dataclass
semantics, CoordinatorClient URL map + token minting + storage reads +
truncation signalling, tool prepare/exec dispatch and approval gating,
CoordinatorManager create / rollback / eviction / lazy rehydration +
concurrency, HTTP endpoint auth + 404-on-ownership + 503-on-misconfig,
proxy-auth ``src`` preservation, full lifecycle end-to-end, coordinator
page HTML-injection guard.  ``test_tools_schema.py`` widened to 25
tools (19 existing + 6 coordinator).

Verification: ``ruff check`` clean, ``mypy turnstone`` clean
(156 files), ``pytest`` 4054 passed (5 pre-existing failures unrelated
to this change — confirmed against ``main``).

* polish(coordinator): address PR review + CI + tool-namespace isolation

CI:
- `ruff format`: two files reformatted, matches the in-repo pre-commit config.
- `wheel-completeness`: add `turnstone/console/static/coordinator/*.html` +
  `*.js` to the hatch wheel-include list.  Without this the coordinator UI
  was missing from published wheels.
- `test (3.11/3.12/3.13)` + `test-postgres`: three `TestExecReadImage`
  tests were masking a real bug — my 6 new tool JSONs pushed tool count
  19→25, crossing the default `tool_search.auto` threshold (20), which
  made `ChatSession.__init__` construct a `ToolSearchManager` and cache
  `_cached_capabilities` during init.  Tests that later patched
  `session._provider.get_capabilities` saw the cached value instead.
  Root-cause fix: the tool-search threshold code path now reads
  capabilities through `_resolve_capabilities(...)` directly — no cache
  populate — so the patch takes.

Tool-namespace isolation (bigger fix than CI symptoms suggested):
- `TOOLS` was the union of all loaded tool JSONs including the 6 new
  coordinator tools.  Interactive sessions were getting coordinator
  tools in their function-calling surface (which is nonsense — they
  require a console-hosted `coord_client`), and coordinator sessions
  counted against the interactive tool-search threshold.  Fix:
  - New `INTERACTIVE_TOOLS` / `INTERACTIVE_TOOL_NAMES` in
    `turnstone/core/tools.py` exclude anything with `coordinator: true`
    metadata.  `TOOLS` stays as the union for schema introspection +
    eval catalog.
  - `ChatSession.__init__` selects tool set by kind: coordinator gets
    fixed `COORDINATOR_TOOLS` (no MCP merge, no listeners registered);
    interactive gets `INTERACTIVE_TOOLS` (+ MCP if configured).
    Coordinators are meta-orchestrators that spawn child workstreams;
    MCP tools / resources / prompts live on the children, not on the
    coordinator's own surface.
  - `_on_mcp_tools_changed` no-ops for coordinator sessions
    (defence-in-depth in case listeners were registered).
  - `always_on_names` on `ToolSearchManager` is now the set of builtin
    tools actually present in the session (kind-aware) rather than the
    full `BUILTIN_TOOL_NAMES` frozenset.
  - `turnstone/eval.py` uses `INTERACTIVE_TOOLS` (coordinator tools
    aren't in scope for the eval harness which tests interactive agent
    behaviour).
  - Regression tests in `tests/test_workstream_kind.py`:
    - `INTERACTIVE_TOOLS ∩ COORDINATOR_TOOLS == ∅` and their union is
      `TOOLS`.
    - Interactive `ChatSession._tools` does not include any
      coordinator tool name.
    - Coordinator `ChatSession._tools` contains `spawn_workstream` but
      not `bash` / `edit_file` / `memory`; sub-agent lists are empty.
    - Coordinator `ChatSession` with an MCP client attached does NOT
      merge MCP tools and does NOT register any MCP listeners.

PR review findings:
- **#10 / #11** (Copilot): coordinator UI claimed to reuse the server
  renderer pipeline but loaded none of its JS.  Mirrored
  `turnstone/ui/static/renderer.js` into
  `turnstone/console/static/coordinator/renderer.js` (flagged in-file
  as a cleanup candidate to promote into `shared_static/`), added
  `katex.min.js` / `highlight.min.js` / `renderer.js` script tags to
  `coordinator/index.html`.  `coordinator.js` now buffers raw markdown
  via `textContent` during streaming, then swaps to `renderMarkdown` +
  `postRenderMarkdown` on `stream_end`.
- **#7** (Copilot): N+1 query pattern in
  `CoordinatorClient.list_children()` — per-row `storage.get_workstream`
  just to read `skill_id`.  Pushed `skill_id` + `skill_version` into
  the `list_workstreams` SELECT projection on both backends; the
  client reads them from `row._mapping` directly.  New
  `test_list_children_skill_filter_avoids_n_plus_one` pins the
  behaviour (asserts `storage.get_workstream` call count is 0).
- **#8 / #9** (Copilot): `spawn_workstream` tool JSON said "if empty,
  the workstream is created idle" but the prepare method rejected
  empty and the field was marked required.  Resolved by allowing
  empty end-to-end: removed from `required`, prepare builds a
  "spawn idle workstream" header + empty preview when empty,
  updated `test_spawn_prepare_allows_empty_initial_message`.
- **#1–#5** (github-code-quality): five asserts with side-effecting
  method calls in `test_coordinator_manager.py` (`mgr.close`,
  `mgr.open`, `mgr.create` in a dead `_c = ...`).  Extracted each
  call to a local variable so `python -O` can't strip the side
  effect.

Verification:
- `ruff check turnstone tests` clean.
- `mypy turnstone` clean (156 source files).
- `pytest -m "not live"` — 4063 passed, 3 deselected (live-backend
  tests), 0 failed.  The 3 image tests that were failing on this
  branch now pass; wheel + lint both green locally.

* polish(coordinator): address Copilot re-review findings

Two findings from the re-review of #368 after the first polish commit.

**user_id wired into `mgr.create()` at the server handlers.** Phase 1
added ``user_id`` to the ``Workstream`` dataclass and
``WorkstreamManager.create()`` signature, but the two call sites in
``turnstone/server.py`` forgot to pass the authenticated caller
through.  Result: interactive workstreams created via
``POST /v1/api/workstreams/new`` (including coordinator-spawned
children, which route through this handler) were landing with blank
``user_id``, defeating ownership-based access control on subsequent
sends / approvals / closes (``_require_ws_access`` treats blank
owners as legacy/allowed).  Two changes:

- ``server.py:create_workstream`` forwards ``user_id=uid`` — the same
  ``uid`` already resolved from the auth result (with trusted-service
  forwarding preserved).
- ``server.py:open_workstream`` prefers the persisted owner on the
  workstream row over the rehydrating caller so reloading someone
  else's workstream doesn't silently re-parent it.  Falls back to
  the authenticated caller when the stored row has no owner
  recorded (pre-phase-1 rows).

Regression test in ``tests/test_workstream.py`` pins
``WorkstreamManager.create(user_id=X)`` → ``ws.user_id == X`` so the
manager seam can't regress silently on a future refactor.

**Malformed-JSON recovery allowlist expanded for coordinator args.**
``_prepare_tool()`` has a two-stage salvage path for models that
emit malformed JSON: a regex-extract (fallback 1) and a bare-string
→ primary_key wrap (fallback 2).  The fallback-1 key list didn't
include coordinator argument names, so a slightly malformed
``spawn_workstream`` / ``send_to_workstream`` / etc. call would
hard-fail instead of salvaging into a minimal-args dict for retry.
Added ``ws_id`` / ``message`` / ``initial_message`` / ``parent_ws_id``
to the allowlist (kept alphabetised) so the coordinator tools get
the same model-self-correction behaviour as the interactive tools.
Fallback 2 already covers the ``ws_id``-primary-key tools via
``PRIMARY_KEY_MAP``; the regex path matters when the model emits
``{"ws_id": "abc", "message": "..."}`` with a trailing syntax error.

Verification: ``ruff check`` clean, ``mypy turnstone`` clean
(156 source files), ``pytest -m "not live"`` → 4065 passed, 3
deselected (live-backend), 0 failed.

* fix(coordinator): address ultrareview findings on coordinator workstream kind

Security
- Cross-tenant leak: CoordinatorClient.inspect/list_children now constrain
  to the coordinator's own ws_id + direct children; an LLM coerced via
  prompt injection can no longer exfiltrate other tenants' workstreams.
- Empty-owner short-circuit bypass: strict equality at coordinator.py
  ownership gate and at the storage-fallback branch in coordinator_history;
  orphan/system-owned coordinator rows can no longer be rehydrated by
  arbitrary holders of admin.coordinator (DoS + history disclosure vector).
- Closed coordinators no longer silently resurrect on subsequent GET —
  the Close button is now actually durable across URL revisits and tab
  refreshes; rows with state in {closed, deleted} refuse rehydration.

Correctness
- ChatSession.close() now releases the CoordinatorClient httpx.Client
  pool; previously every closed/evicted coordinator dropped a connection
  pool on the floor until non-deterministic GC.
- open_workstream rehydration now forwards parent_ws_id + kind, so
  coordinator-spawned children survive node restart / idle eviction
  with their parent link intact instead of becoming silent orphans.
- list_children truncated flag now signals whenever the SQL fetch hit
  the page cap (previously permanently False in the no-filter case,
  causing confident-but-incomplete summaries from the coordinator).
- ConsoleCoordinatorUI.approve_tools: per-tool auto-approve now checks
  auto_approve_tools independently of the blanket auto_approve flag,
  so 'Always approve this tool' actually works on the next invocation.

Concurrency
- _spawn_worker no longer falls through to start a second concurrent
  worker thread on the same ChatSession when queue.Full fires; instead
  send() returns False and the endpoint surfaces HTTP 429.
- _open_locks entries are now refcounted under self._lock and only
  popped when the last waiter releases — eliminates the race where a
  rehydration-failure path lets two threads serialize on different lock
  instances for the same ws_id and trip the "already tracked" guard.

Tests: +6 regression cases covering closed-coordinator refusal,
empty-owner non-admin refusal, queue.Full no-duplicate-worker,
inspect/list_children cross-tenant rejection, and truncated semantics.
2026-04-16 21:40:50 -07:00
Patrick Buckley a917bf2690 docs: apply Copilot review feedback on PR #367
All eight suggestions verified against source before applying:

- docs/settings.md — ConfigStore key names are `model.plan_alias` /
  `model.task_alias` (not `plan_model` / `task_model`); updated in
  both the overview list and the plan/task overrides table.
- docs/security.md — `src` claim values now reflect what actually
  gets minted: `password`, `database` (from API-token exchange),
  `oidc`, plus service origins `console`, `cli`, `channel`.
- docs/sdk.md — `upload_attachment(ws_id, filename, data, *,
  mime_type=...)` matches the real SDK signature; `bytes`-returning
  helper is `get_attachment_content` (not `download_attachment`);
  code example reordered so it doesn't collide on `filename=` kwarg.
- docs/architecture.md — "prior `plan` tool call" → "prior
  `plan_agent` tool call" so wording stays consistent with the
  renamed tool.
- docs/tools.md — `plan_agent` `primary_key` is `goal`, not
  `prompt`, in both the primary-key table and the summary table
  (matches the JSON schema in turnstone/tools/plan_agent.json).
2026-04-16 16:01:24 -07:00
Patrick Buckley 471d1a3311 docs: audit documentation for 1.4 / 1.5 state
Systematic pass over every doc under docs/, the root-level README /
QUICKSTART / CONTRIBUTING, and the PlantUML diagrams.  Memory and docs
had drifted against the code since 1.2 — this catches them up to the
1.4.0 release and the 1.5.0a1 experimental line.

User-facing fixes
- README: fix broken docs/mcp.md link (→ mcp-registry.md); channel
  gateway entry reflects shipped Discord + Slack adapters instead of
  "Slack/Teams planned"; diagrams table mentions both.
- QUICKSTART: docs/*.md relative links were wrong from the repo root;
  wizard version bumped from 0.5.4.
- CONTRIBUTING: add dev extra plus the ruff / mypy / pytest commands
  we actually expect before push.

Reference docs
- architecture.md: 19 tool schemas (was 15), 18 admin tabs (was 14),
  turnstone-bootstrap added to entry-points table, OpenAI provider
  file split (chat/responses/common) documented, 38 SDK event
  dataclasses (was 27 and referenced deleted mq/protocol.py), Slack
  adapter + multi-adapter gateway, plan_agent/task_agent naming,
  governance admin-panel rewrite.
- api-reference.md: full attachment endpoints (POST/GET/content/
  DELETE on /v1/api/workstreams/{ws_id}/attachments) plus the
  multipart mode on POST /v1/api/workstreams/new.
- channels.md: Slack Setup section (Socket Mode app creation, OAuth
  scopes, tokens), Slack CLI/env reference in config table, combined-
  adapter architecture diagram.
- console.md: 18-tab listing (was 13) with Channels/Models/Nodes/TLS
  descriptions and ConfigStore live-edit note.
- docker.md: Slack env vars block; image entry-point list now
  includes turnstone / turnstone-bootstrap.
- sdk.md: attachments methods on the server client, attachments
  example (upload-then-send and at-creation), event count fixed.
- releasing.md: four-track table (stable/1.0, 1.3, 1.4 + main 1.5);
  promotion workflow uses 1.5 / 1.6 numbering.
- settings.md: plan_model / task_model / plan_effort / task_effort
  overrides section.
- governance.md: skill naming (/skill, `skill` field — not /template),
  Prompts/Judge tabs called out.
- security.md: two-token-types wording; src claim values match the
  AuthResult source strings actually emitted.
- mcp-registry.md: SDK package name is @turnstone/sdk.
- tools.md: plan / task renamed to plan_agent / task_agent in the
  section headings and summary table; primary-key table matched.
- design/consistent-hash-ring.md: dead direct-http-transport.md
  pointer redirected to architecture.md.

Diagrams
- 02-package-structure: drop phantom chat.py entry point, add admin
  and bootstrap, add slack/bot.py, rename channels/gateway.py →
  channels/cli.py.
- 16-channel-architecture: Slack is no longer "(future)", add a
  SlackBot class and the slack-bolt Socket Mode edges; wire the new
  bot into ChannelService.  PNGs regenerated from both puml sources.
2026-04-16 16:01:24 -07:00
Patrick Buckley 879be89bbd docs: enrich CHANGELOG and add Contributors section for 1.4.0
Audit pass against the actual commit messages between v1.3.0 and
v1.4.0 turned up several substantive items the initial CHANGELOG
under-described or omitted entirely.  Fix-forward expansion plus a
Contributors section recognizing external contributors.

Added detail / coverage:

- New "Server compatibility layer for local model servers" entry —
  the vLLM / llama.cpp profiles + admin UI fields shipped in #352
  alongside the capabilities passthrough; previously buried under one
  bullet.
- Per-call plan/task model selection split into three sub-bullets
  (backend split, runtime configurability without restart via
  ConfigStore admin tab, per-call override) — three PRs that build on
  each other deserve to be discoverable independently.
- Opus 4.7 entry expanded with 1M ctx / 128K output, the new
  thinking_display capability field, xhigh effort level, and admin
  dropdown updates.
- Dashboard composer note: tab-bar `+` modal also gained the paperclip
  + chip strip + first-message field.
- Slack adapter: explicit "session recovery via persisted recoverable
  route keys" — ops-relevant promise for restart behaviour.
- pgbouncer swap: helm chart link + ports updates noted.
- Provider capabilities entry: defensive shallow-copy + chat_template
  deep-merge follow-ups.

New Fixed entries:

- Cross-user attachment-fetch hardening (get_attachment_content
  scopes by user_id).
- Attachment-list DoS guard on /v1/api/send.
- Bounded LRU for upload locks.
- 3.12 CI deadlock root-cause writeup (asyncio.Lock vs Starlette
  TestClient loop teardown).

New SDK entry:

- PlanResolvedEvent type + guard, dispatched cross-client when one
  client resolves a plan so others dismiss in sync.

New Operational subsection:

- vendor-js workflow now auto-downloads hls.js for future Renovate
  bumps so they're merge-ready without manual file fetches.

Contributors:

- Recognise @daoxley (Slack adapter, #355) and @pizzaandcheese
  (pgbouncer swap, #353) — the two external contributors with
  meaningful net-new work in this release — plus the Renovate bot.
- Pointer to channel-attachment ingest as the headline 1.4.1 feature
  for would-be contributors.
2026-04-16 15:16:19 -07:00
Patrick Buckley 86d981eb73 chore: bump version to 1.5.0a1 2026-04-16 15:07:53 -07:00
Patrick Buckley b1e7c82e95 docs: add CHANGELOG.md for the 1.4.0 release
Repo previously had no CHANGELOG.  Establishes the file with full
1.4.0 coverage (attachments end-to-end, dashboard composer refactor,
Slack adapter, per-call plan/task model, provider capability
passthrough, Opus 4.7) plus a one-line 1.3.1 entry for the Opus 4.7
backport.  Format follows Keep a Changelog 1.1.0; release-track
guidance up top covers the three stable branches + main.

Operator-relevant call-out at the top of [1.4.0]: migrations 037 +
038 must be applied before starting 1.4.0 against an existing 1.3.x
database.  Both are additive and idempotent.
2026-04-16 15:05:39 -07:00
Patrick Buckley aff449116e feat(ui): dashboard composer polish from PR #362 designer review (#366)
* feat(ui): dashboard composer polish from PR #362 designer review

Three deferred items from the prior designer pass on the unified
dashboard composer.  Pure UX affordances; no server change.

- **Persist Options open/closed in localStorage.**  Power users who
  routinely set non-default model/skill don't have to click "Options"
  on every page load.  Key: `turnstone.dashboard.options_open`.
  Defaults closed for first-time users.  Falls back gracefully when
  localStorage is unavailable (private mode, quota).

- **Active-options summary chip.**  Renders the non-default model /
  judge / skill values inline next to the Options button (mono, dim,
  separated by middots).  Hidden via `[hidden]` when everything is at
  default — no chrome cost in the common case.  Updates on any select
  change via a single delegated handler on the panel.  Hidden on
  narrow viewports (the action row stacks vertically there and the
  chip would push the layout further).

- **"Drop to attach" overlay during drag.**  CSS pseudo-element on
  `.dashboard-composer-drop` overlays a centered "Drop to attach"
  label so dragging a file makes the action explicit instead of just
  showing the dashed-border highlight.  pointer-events: none keeps
  the underlying composer controls reachable; visual only.

* fix(ui): address Copilot review on dashboard composer polish

- _restoreDashboardOptionsState() forced the panel closed every time
  showDashboard() ran when localStorage was unavailable (private mode,
  storage quota), contradicting the comment that promised a per-session
  fallback.  Add a module-scoped _dashOptionsOpenSession variable
  updated by _setDashboardOptionsOpen / _toggleDashboardOptions, and
  only override the visible state from localStorage when the read
  genuinely succeeded.  The session value now preserves the user's
  choice across hide/show cycles in environments where localStorage
  throws.

- Fold the duplicated `.dashboard-composer { position: relative; }`
  block into the existing rule above.  The position context is needed
  for the .dashboard-composer-drop::before overlay; the comment now
  says so.
2026-04-16 14:54:25 -07:00
renovate[bot] ddf7b3c2f0 chore(deps): lock file maintenance 2026-04-16 14:45:22 -07:00
Patrick Buckley a6c6b71d66 feat(console-ui): support Slack channel_type in admin UX
PR #355 added the Slack adapter on the server but missed the console
admin surfaces that talk to channel_type.  Three concrete gaps + a
designer-review polish pass.

Functional bug + UI parity:

- _collectNotifyTargets() in admin.js hardcoded `channel_type: "discord"`
  — even on a Slack-only deployment the skill notify-on-complete form
  always wrote Discord targets, sending notifications to the wrong
  adapter (or nowhere).  Add a per-row channel-type <select> driven by
  a small _NOTIFY_CHANNEL_TYPES table that's the one place to register
  a new platform; collector and populator both read from the dropdown.
  ID-input placeholder updates dynamically when the platform changes.

- The "Link Channel Account" modal only offered Discord — users
  couldn't link a Slack account through the UI at all.  Add a Slack
  <option> and reuse the same dynamic-placeholder helper.  Drop the
  static Discord-shaped HTML placeholder so the JS-driven hint doesn't
  flash a Discord example before the dropdown initializes.

- Skill create/edit modals only showed Discord in the notify-on-complete
  placeholder example.  Show both adapters.

- Per-platform .scope-discord / .scope-slack badge classes so the
  linked-accounts list distinguishes platforms visually instead of all
  rendering as the generic .scope-channel magenta.  Falls back to
  .scope-channel for any future channel_type the stylesheet doesn't
  yet know about.

Designer review polish:

- Theme-aware --discord / --slack / --discord-glow / --slack-glow
  tokens in base.css.  The first pass shipped raw hex (#818cf8 /
  #f472b6) that fails WCAG AA on light theme (1.8:1 and 2.4:1); the
  light variants (#4f46e5 indigo, #be185d rose) pass.  Badge classes
  now reference tokens, matching every other .scope-* rule.

- Notify-row mobile layout: three controls in a row left ~80px for
  the ID input at 360px viewport, truncating snowflakes.  Tighten
  platform select to 76px (labels are short), add flex-wrap, and at
  ≤700px drop the ID input to its own row so it gets full width.

- Per-platform classes apply alone (not co-classed with scope-channel)
  so winning the cascade doesn't depend on stylesheet source order.

- Replace "Discord snowflake" jargon with "Discord ID"; give Slack
  ids concrete examples (C01234567 / U01234567) instead of an
  ambiguous "C0…".
2026-04-16 14:45:01 -07:00
Patrick Buckley 0d3516d6e0 fix(slack): post-merge fixes from Copilot + eous review
Combines the substantive bot.py fixes flagged in both review trails on
PR #355.  Discord parity items grouped here too since they're the same
surface (slack/bot.py).

From Copilot:

- _notify_reply_routes was read on StreamEndEvent but never popped on
  the success path.  Result: one notification reply pinned every later
  response for that ws_id to the notification thread until the bot
  restarted.  Pop after read; combine the surrounding ifs (SIM102).

- PlanReviewEvent embedded raw event.content inside a triple-backtick
  mrkdwn fence without escaping.  A plan with ``` (very common — plans
  often quote code) would break the fence and let later content render
  as live markup, including unintended Slack mentions/links.  Rewrite
  _sanitize_slack_preview to splice a zero-width space inside any ```
  sequence (Slack stops recognizing it as a delimiter) instead of
  escaping every single backtick — keeps single-backtick code snippets
  readable while still protecting the fence.  Apply to plan-review.

- _send_approval_request joined unbounded tool_lines into one mrkdwn
  section, but Slack section.text caps at 3000 chars.  Multi-tool
  batches with large previews silently failed chat_postMessage,
  leaving the user unable to approve/deny.  Cap each preview to 600
  chars under a 2700-char total budget; append "+N more" when truncated.

From eous (parity with Discord):

- Pass `client_type="chat"` from both `get_or_create_workstream` call
  sites (slash-command session + DM).  Without it Slack-routed
  workstreams loaded the web-default prompt; the chat-specific
  system prompt now applies as it does for Discord.

- Add `exc_info=True` to the eleven `log.debug(...)` exception handlers
  so underlying tracebacks are available when debug logging is on
  instead of being silently dropped.  Level stays debug — these are
  benign-by-default sites (chat_update on a deleted message, etc.) so
  only the visibility changes.  Typed-exception handlers
  (RemoteProtocolError, etc.) keep their bare debug log.

- Module docstring on slack/__init__.py so pydoc / import errors have
  human-readable context.

Tests: rewrite the sanitizer test to match the new (more permissive)
single-backtick behaviour; add coverage for the triple-backtick
neutralization + short-input passthrough; patch httpx.AsyncClient at
all five TurnstoneSlackBot construction sites so each test doesn't
leak an unclosed real client.
2026-04-16 14:45:01 -07:00
Patrick Buckley a8dcccafa3 chore(slack): unblock CI + dependency cleanup after #355
- cli.py: ChannelAdapter import is annotation-only; move into
  TYPE_CHECKING block and switch the two cast() calls to string-form
  so the runtime import isn't required (TC001).
- slack/{config,routes}.py: ruff format fixes (whitespace + drop
  redundant string-form annotation now that __future__ annotations
  is in effect).
- pyproject.toml: drop the unused `tests.*` mypy override — `mypy
  turnstone` (the only invocation in CI + local) never matches it,
  so it was pure noise in the "unused section(s)" report.  Other
  optional-dep overrides stay; they're real safety nets when running
  mypy without the [all] extras (e.g. on the test job).
- uv.lock: regenerate to match the slack-bolt + transitive deps the
  pyproject changes resolve to (lock-check was failing on stale hash).
2026-04-16 14:45:01 -07:00
renovate[bot] e19032f369 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.11.7 (#364)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-16 14:28:22 -07:00
daoxley d3ff5e5ac7 Add Slack channel adapter with Socket Mode support (#355)
* Add Slack channel adapter with Socket Mode support

Adds a Slack channel adapter mirroring the Discord adapter pattern:

- Socket Mode connection
- Per-user session management in channels via configurable slash command
- SSE-based event consumption from server nodes
- Tool approval buttons with policy evaluation support
- DM routing without slash command (requires Slack app DM permissions; not validated in current workspace)
- Session recovery after restart via recoverable route keys

New files:
- turnstone/channels/slack/bot.py
- turnstone/channels/slack/config.py
- turnstone/channels/slack/__init__.py
- tests/test_channel_slack.py

Updated:
- turnstone/channels/cli.py — adds Slack CLI arguments
- pyproject.toml — adds slack extra and mypy overrides

Usage:
- Install with Slack support:

* Fix lint issues and update lock file

* fix: unify channel startup, fix Slack notification reply routing, add plan review UI/actions

* Fixes to notification responses

* Add turnstone/channels/slack/routes.py
Update bot.py to import shared SlackRoute
Update _http.py Slack notify validation

* Add approval guards

* Only creators can approve

* Updated slack test suite

* use integer tuple comparison for Slack timestamp ordering

* suppress mypy no-untyped-call for slack_bolt socket mode handler
2026-04-16 13:45:46 -07:00
Patrick Buckley a0b3c35d28 Feat/attachment followups (#363)
* fix(ui): rehydrate chip strip after queued-message dequeue not_found

The dequeue handler only refreshed the per-pane chip strip when the
DELETE returned status="removed". On status="not_found" (the queued
message already dispatched), chips stayed stale: any reservations that
raced the dispatch could leave the UI showing a different pending set
than the server actually had.

Re-fetch on both paths so the chip strip always reflects the
authoritative server state. The queued-message bubble itself stays
visible on not_found, same as before — the promote loop strips the
queued styling on idle.

* feat: sweep orphan attachment reservations periodically

Process crashes between reserve_attachments and consume/unreserve can
leave attachment rows soft-locked forever (reserved_for_msg_id NOT NULL
with no consumer ever coming back). The worker-thread exception path
in /v1/api/send already handles in-process failures, but a hard kill
or oom mid-send escapes that.

Add sweep_orphan_reservations(older_than_seconds) to the storage
protocol — clears reserved_for_msg_id on rows where message_id IS NULL
and created < now() - threshold. Implemented for SQLite + PostgreSQL
using the same string-comparison form (created is ISO-8601 text in
both backends, lexicographic order matches chronological).

Wire into the server lifespan: run once at startup (catches anything
left over from the previous process), then every 30 minutes as
defense-in-depth. Threshold is 4 hours so we don't race a long-running
dispatch and unreserve rows the worker is still about to consume.

Tests cover sweep semantics: clears old reserved rows, leaves fresh
ones alone, skips already-consumed rows, no-ops on zero/negative
threshold.

* fix: track reserved_at for orphan-reservation sweep

Copilot review on PR #363 flagged a real correctness bug: the sweep
used the attachment row's `created` timestamp (upload time) as the
staleness signal. An attachment uploaded hours ago but reserved fresh
could be unreserved mid-send, after which mark_attachments_consumed
silently drops the row because reserved_for_msg_id no longer matches
the send_id.

Add a dedicated `reserved_at` column (migration 038) set on
reserve_attachments and cleared on mark_attachments_consumed /
unreserve_attachments. The sweep now scopes by `reserved_at < cutoff`,
so reservation age is what's measured, not upload age. Backed by a
partial index `(reserved_at) WHERE reserved_at IS NOT NULL` so the
periodic scan stays cheap as the consumed-history grows.

Threshold dropped from 4h to 1h since it now means "longest realistic
single send" rather than "longest plausible time between upload and
send" — a tighter, more defensible bound.

Tests cover the regression (uploaded long ago + reserved fresh must
not be swept), plus reserved_at clearing on both consume and unreserve.
2026-04-16 13:39:32 -07:00
Patrick Buckley 6cbd3eb2c1 feat: workstream attachments at creation time + SDK + UI parity (#362)
* feat: workstream attachments at creation time + SDK + UI parity

Closes the two big deferred items from PR #356: attaching files as part
of the initial workstream-creation request, and full SDK coverage of the
attachment surface.

Server: POST /v1/api/workstreams/new now accepts multipart/form-data
(meta JSON + 0..N file parts).  Files are validated and saved as pending
under the new ws; when initial_message is also set the create handler
reserves them onto that turn before the dispatch worker fires, mirroring
the /v1/api/send pattern.  Validation failure rolls back the workstream
via delete_workstream so we don't leak orphan rows or emit a phantom
ws_created/ws_closed pair on SSE.  JSON path is unchanged.

Console routing: route_create accepts multipart with ?ws_id=<hex> as a
query parameter (the console hashes the id before the body lands).
Added /v1/api/route/workstreams/{ws_id}/attachments POST/GET/DELETE +
.../{attachment_id}/content GET proxies that forward raw bytes and
preserve upstream headers (Content-Disposition, X-Content-Type-Options,
CSP sandbox).

Python + TypeScript SDKs: AttachmentUpload type, upload_attachment,
list_attachments, get_attachment_content, delete_attachment, and
send(attachment_ids=...).  create_workstream(attachments=...) sends
multipart and pre-generates a ws_id client-side so cluster routing
works.  SDKs reject attachments+target_node combinations since the
multipart route doesn't honor target_node.

Web UI: dashboard composer refactored to a single unified create flow.
Replaced the inconsistent split (Enter created+sent raw, "New Chat"
opened a modal) with one rich composer carrying a textarea, paperclip
+ chip strip, drag-drop, paste-image, and a collapsible Options panel
for model/judge_model/skill.  Submit button dynamically labels Create
vs Send.  New-workstream modal also gained the same paperclip + chip
strip + first-message field for the tab-bar + entry point.

Tests: 30 new tests across server multipart create, console route
multipart + attachment proxies, Python + TS SDK attachment surfaces,
plus regressions for the three review-flagged bugs (Content-Type
boundary preservation, attachments+target_node rejection, no phantom
ws_created on validation failure).

* fix: address Copilot review feedback on PR #362

- web_helpers: docstring now matches behaviour — read_multipart_create_or_400
  does enforce the optional max_per_file_bytes cap as defense-in-depth.
- app.js: drop the duplicated _formatAttachSize definition (one already
  exists earlier for pane chips); add a shared _isAttachmentAllowed helper
  that mirrors the server's classifier (png/jpeg/gif/webp images, text/*
  MIMEs, allowlisted application/* MIMEs, known text extensions) and call
  it from both _newWsAddFiles and _addDashboardFiles so unsupported files
  fail fast client-side instead of after a server roundtrip.
- app.js: dashboardSubmit catch now suppresses the redundant error toast
  on authFetch's "auth" Error and falls back to a generic message when
  err.message is undefined, instead of rendering "Connection error: undefined".
- SendResponse (Pydantic + TS): document and expose attached_ids,
  dropped_attachment_ids, priority, and msg_id so attachment-aware SDK
  callers can detect partial reservations and dequeue queued messages.
- test_server_attachments_on_create: drop the dual `import turnstone.server`
  + `from turnstone.server import` style — use monkeypatch.setattr by
  dotted path for module-level mutation and `from … import …` for the
  helpers, keeping a single import style.
2026-04-16 13:30:25 -07:00
Patrick Buckley 551fc43c15 feat: per-call model selection on plan_agent / task_agent (#361)
* feat: per-call model selection on plan_agent / task_agent

The calling LLM can now pass `model="<alias>"` to plan_agent or
task_agent to override the operator-configured per-kind model for
that one invocation.  Useful when subtask difficulty varies within a
session: the model can downgrade to a cheap alias for trivial work
and reach for a stronger one when the problem is hard.

Tool descriptions list the live registered aliases (refreshed when
the operator hits "sync to nodes" / internal_model_reload), so the
calling LLM always sees the current options.  Bad aliases return a
corrective error dict with the available choices so the LLM retries
cleanly rather than failing silently.

No whitelist — any alias the registry knows is acceptable; cost
control is intentionally ceded to the model.  No per-call effort
override (out of scope; effort stays operator-configured).

Resolution precedence in _run_agent: explicit per-call agent_alias
override > registry per-kind (plan_model/task_model) > legacy
agent_model > session model.  The plan retry path (when
_validate_plan fails) reuses the same alias so coaching reflects
real model behaviour rather than a different model masking the
signal.

Implementation:
- plan_agent.json / task_agent.json: optional `model` parameter.
- ChatSession._validate_agent_model_override extracts and validates
  the arg; mirrors the existing empty-prompt error pattern.
- _prepare_plan / _prepare_task stash the override in
  item["model_override"]; _exec_* pass it through.
- _run_agent gains agent_alias kwarg with defence-in-depth
  ValueError on unknown alias.
- _render_agent_tool_descriptions deep-copies plan/task entries
  before mutating description so the module-level TOOLS constant
  stays untouched across sessions; rebuilds the BM25 tool-search
  index when active so its text matches what the LLM sees.
- server._broadcast_agent_tool_schema_refresh walks active
  workstreams on internal_model_reload so descriptions update
  without restart.

* fix: clarify no-registry placeholder + avoid double BM25 rebuild

Addresses Copilot feedback on PR #361.

1. plan_agent.json / task_agent.json placeholder said the parameter
   falls back to the "operator-configured plan/task model".  That
   text is what no-registry sessions see (registry-bearing sessions
   get the templated description with the live alias list); for
   those single-model sessions, omitting the param falls back to
   the current session model, not an operator-configured one.
   Reword so the no-registry user gets accurate guidance.

2. _on_mcp_tools_changed already calls _rebuild_tool_search after
   merging MCP tools.  _render_agent_tool_descriptions also
   rebuilt the BM25 index when active, so the MCP refresh path
   was rebuilding twice per refresh.  Move the BM25 rebuild out
   of the private render helper into the public
   refresh_agent_tool_schemas wrapper — _on_mcp_tools_changed
   keeps calling the render helper directly (no double rebuild),
   and registry-reload callers go through the wrapper which
   still keeps the index in sync.
2026-04-16 11:50:13 -07:00
Patrick Buckley 6c026710ff feat: ConfigStore + admin UI for plan/task agent model and effort (#360)
* feat: ConfigStore + admin UI for plan/task agent model and effort

Per-kind sub-agent routing was added in #359 but only via config.toml.
Operators can now switch the plan_agent / task_agent model and reasoning
effort at runtime from the admin Model tab without restarting.

Adds four ConfigStore-backed settings:
  model.plan_alias    — alias for plan_agent
  model.task_alias    — alias for task_agent
  model.plan_effort   — reasoning effort for plan_agent
  model.task_effort   — reasoning effort for task_agent

Server startup and internal_model_reload both apply these as overrides
on top of the registry's config.toml-loaded values; the new logic
computes "effective" values for all five model-routing fields and only
calls registry.reload() when at least one differs.

Admin UI: extracts ALIAS_SETTING_KEYS to a const used by both the
dynamic-alias-choice injection and the empty-option label rendering.
Adds INHERIT_EMPTY_LABEL_KEYS so plan_effort / task_effort show
"(inherit)" for empty — distinct from the literal "none" choice (which
actually disables reasoning, very different from leaving unset).

Also fixes Copilot review feedback from #359:
  - _validate_effort treats empty / whitespace as unset rather than
    warning on benign explicit-empty configs (with .strip().lower()
    normalisation; "HIGH" and " low " now parse correctly)
  - turnstone.example.toml's reasoning_effort comment lists the full
    set of accepted values (none, minimal, low, medium, high, xhigh, max)

* fix: apply routing overrides on config-reload + skip no-op model-reload

Addresses Copilot feedback on PR #360.

1. Admin settings updates fan out via /_internal/config-reload, which
   only reloaded the ConfigStore — plan/task routing changes weren't
   visible until a model-reload or restart, defeating the runtime
   configurability this PR is meant to add.

2. /_internal/model-reload always called registry.reload(), churning
   cached clients even when nothing changed. Risky when fanned out
   across nodes (could close in-flight clients).

Extracts two helpers in server.py:
  - _effective_routing(cs, ...)  pure function: overlay CS values on base
  - _apply_routing_overrides(reg, cs)  reload only when something differs

Used by the startup path, config_reload (new), and model_reload (now
short-circuits with a noop response when models + routing are unchanged).
2026-04-16 11:08:26 -07:00
Patrick Buckley 54dd557476 feat: split plan_model and task_model, configurable agent reasoning effort
plan_agent and task_agent previously shared a single agent_model knob and
plan_agent hardcoded reasoning_effort="high" in three call sites. They
have different cost/latency profiles — plan is rare and benefits from a
stronger model, task is frequent and benefits from a cheaper one — so
sharing the knob undertunes both.

ModelRegistry gains plan_model, task_model, plan_effort, task_effort.
Per-kind overrides win over the legacy agent_model, which still works
as the single-knob fallback for both. resolve_agent_alias(kind) and
resolve_agent_effort(kind) centralise the resolution; PLAN_DEFAULT_EFFORT
captures the back-compat "high" default in one place rather than at
every call site.

session._run_agent delegates resolution by label ("plan" vs "task").
The three hardcoded reasoning_effort="high" arguments are removed —
behaviour is identical when no plan_effort is configured.

Loader validates effort against {none,minimal,low,medium,high,xhigh,max}
and warns + drops typos rather than passing them to the provider.

ConfigStore parity and admin UI for the new knobs are deferred to a
follow-up — config.toml-only is enough for the backend split.
2026-04-16 10:26:19 -07:00
Patrick Buckley 87a9af1075 fix: broadcast plan_resolved SSE so other clients dismiss in sync
Previously, resolving a plan on one client (e.g. phone) cleared the
server's pending state and unblocked the worker, but emitted no event
to other connected clients. Their plan-approval modal stayed stuck.

resolve_plan() now enqueues a plan_resolved frame (mirroring the
approval_resolved pattern in resolve_approval) before clearing
_pending_plan_review, so a reconnecting client cannot receive both
the replayed plan_review and the live plan_resolved. Skips the frame
on the cancel-with-no-plan path.

Client adds a plan_resolved handler that dismisses the modal without
re-firing /v1/api/plan, restores keyboard context (skipped on touch
to avoid soft-keyboard pop on mobile), labels the inline plan summary
"(synced)" so remote dismissal is unambiguous, announces via the
existing aria-live #toast for screen-reader parity, and falls back
to an info message if plan_resolved races ahead of plan_review.

Adds PlanResolvedEvent to the Python and TypeScript SDKs with
deserialization and type-guard tests.
2026-04-16 09:56:17 -07:00
Patrick Buckley a6c4abe82a chore: bump version to 1.4.0a4 2026-04-16 09:15:03 -07:00
Patrick Buckley 30c89f46c6 feat: add Claude Opus 4.7 support (#357)
- Add claude-opus-4-7 capability entry (1M ctx, 128K output, adaptive
  thinking, supports_temperature=False, thinking_display=summarized)
- Suppress temperature param for Opus 4.7 (API returns 400)
- Add thinking display opt-in via new ModelCapabilities.thinking_display
  field - Opus 4.7 omits thinking by default, always send summarized
- Add xhigh effort level to mapping and Opus 4.7 effort_levels
- Add xhigh/max options to skill template dropdowns in admin console
- Align reasoning effort label capitalization across all console dropdowns
- Update example config to reference claude-opus-4-7
- 10 new tests with regression guards for Opus 4.6 backward compat

Verified against live API: streaming and completion calls succeed.
2026-04-16 08:55:32 -07:00
Patrick Buckley aaea4d302d chore(security): ignore unfixable jq CVEs in Debian 13.4 base image
Trivy flags two HIGH CVEs in jq/libjq1 1.7.1-6+deb13u1 with no fixed
version yet from Debian:

- CVE-2026-39979: out-of-bounds read in jv_parse_sized() on non-NUL-
  terminated buffers
- CVE-2026-40164: DoS via crafted JSON causing hash collisions

jq is invoked only on trusted CLI/admin paths against
process-controlled JSON input in turnstone — never on untrusted
network bytes — so the NUL-terminated invariant holds and the DoS
vector is not reachable.

Will revisit when Debian publishes a patched libjq1.
2026-04-15 13:41:15 -07:00
Patrick Buckley b8daeb3be2 chore: bump version to 1.4.0a3 2026-04-15 13:36:18 -07:00
Patrick Buckley 97fbfb9f8e feat: workstream attachments (images + text documents) (#356)
* feat: workstream attachments (images + text documents)

Adds end-to-end support for attaching images (png/jpeg/gif/webp) and
plain-text documents (markdown, source, JSON, etc.) to a workstream's
next user turn via the web UI.

Storage: new workstream_attachments table (migration 037) with a
three-state lifecycle — pending → reserved → consumed — scoped by
(ws_id, user_id) and linked to conversations.id on consume. Rewind/
truncation cascades attachment rows; delete_workstream does too.

Session: ChatSession.send(attachments, send_id) builds multipart user
content (text + image_url + document parts) and persists text-only to
conversations with attachments joined on load via message_id. Queue
path carries ordered attachment_ids plus a reservation token so
queued multimodal turns can't lose files to overlapping sends.

Providers: internal document content parts translate at the API
boundary — Anthropic emits native document blocks (text/plain
coerced, original MIME folded into title); OpenAI Chat Completions
and the Google OpenAI-compat endpoint inline them as escaped
<document> text blocks (XML-attr escape + </document> neutralization);
Responses API emits input_text with the same wrapper.

Server: POST/GET/DELETE /v1/api/workstreams/{ws_id}/attachments with
multipart upload (magic-byte image sniffing, UTF-8 enforcement for
text, per-kind size caps, Content-Length pre-check, per-(ws,user)
pending cap + TOCTOU lock). /v1/api/send reserves before dispatch
using a full-UUID token, threads it into session.send / queue_message,
releases on worker-thread failure, and reports attached/dropped ids
so the UI can reflect partial reservations. GET /content sets
X-Content-Type-Options, CSP sandbox, inline Content-Disposition, and
forces text/plain for text kinds. Ownership failures mask as 404.

UI: paperclip button, hidden file input with accept allowlist, chip
strip above textarea, drag/drop + paste-image handlers. Chips
rehydrate on ws switch and on queued-message dequeue; send clears
only attached ids and shows a toast when some dropped. Historical
user messages render filename pills via a _attachments_meta sibling
populated on both live-send and reconstruct paths.

530 tests covering CRUD, reservation lifecycle, races (TOCTOU cap,
reserve-then-dispatch overlap), provider translation, XSS headers,
cascade delete, history round-trip, and service-scoped actor flow.

* fix(attachments): address PR review feedback

- get_attachment_content now scopes the row by user_id too, so an
  unowned workstream can't be a vector for cross-user blob fetches
  via attachment_id guessing (Copilot, server.py:2676)
- send_message rejects attachment_ids lists longer than the pending
  cap with 400 — prevents hostile clients from blowing up the
  storage IN (...) clause (Copilot, server.py:1515)
- _attachment_upload_locks switched to a bounded LRU OrderedDict;
  evicts the oldest unlocked entries past the soft cap so the map
  can't grow unboundedly on long-running nodes (Copilot, server.py:2417)
- Pane.dragleave handler uses relatedTarget instead of target so the
  drop-zone styling clears correctly when the cursor moves through
  child elements; dragend listener added as a fallback for cancelled
  drags (Copilot, app.js:297)
- uploadAttachment always cleans up the placeholder chip on failure,
  including auth errors — no more stuck "uploading..." chips after
  re-auth (Copilot, app.js:427)
- New _swapPlaceholderChip / _removeAttachmentChip helpers preserve
  user-selection order through the placeholder→real-id swap; the
  pendingAttachments Map is rebuilt in place rather than naïvely
  delete+set, which would have moved the entry to iteration end
  (Copilot, app.js:420)
- Drop unused `var self = this;` in removeAttachment (github-code-quality)
- Two regression tests: cross-user fetch on an unowned workstream,
  and oversized attachment_ids list rejection

* fix(attachments): switch upload-lock to threading.Lock to avoid 3.12 CI hang

The per-(ws, user) upload lock was a module-cached asyncio.Lock.
Starlette's TestClient runs each request on a fresh anyio task /
event loop, so the cached lock's internal _waiters bind to the first
loop that acquired it.  When a later request runs in a different
loop, await lock.acquire() blocks on a Future from a closed loop —
silent deadlock.

This surfaced as test (3.12) hanging indefinitely in CI on one push
while the same suite passed on 3.11/3.13 and on the next push.  Same
root cause is reproducible against any Starlette TestClient harness
on 3.10+; 3.12 just happens to surface it more often given changes
in how anyio + asyncio.Future interact across loop teardown.

Switched to threading.Lock — loop-agnostic, and the critical section
is one COUNT + one INSERT, short enough that briefly blocking the
event loop is fine.  Updated the LRU-eviction probe accordingly
(threading.Lock has no public .locked(), so use a non-blocking
acquire+release as the "is it free?" probe).

TOCTOU pending-cap test still passes; full attachment suite passes
on both 3.12 and 3.13.
2026-04-15 13:30:22 -07:00
pizzaandcheese 4da751c1c6 replace bitnami pgbouncer with edoburu pgbouncer (#353)
* replace bitnami pgbouncer wit edoburu

replaced bitnami pgbouncer with edoburu pgbouncer container and updated environment variables to fit

* updated ports & Kubernetes

Updated ports to fit existing documentation. Also updated the Kubernetes Helm Chart link to use the same container.
2026-04-14 17:45:53 -07:00
Patrick Buckley 8068ae105d chore: bump version to 1.4.0a2 2026-04-14 11:17:06 -07:00
renovate[bot] 6e99bb8b0b chore(deps): update dependency hls.js to v1.6.16 (#354)
* chore(deps): update dependency hls.js to v1.6.16

* chore: download vendored hls.js files + add hls to workflow detection loop

The wheel-completeness check failed on the Renovate bump because
vendor-js.yml only iterated katex/hljs/mermaid — so hls.js PRs
never got their files auto-downloaded. Adding hls to the loop so
future Renovate bumps are merge-ready without manual intervention.

Also running the update now to fix this specific PR.

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Patrick Buckley <buckleypm@gmail.com>
2026-04-14 11:15:36 -07:00
Patrick Buckley eb59cdefda feat: pass resolved capabilities through to providers, add server com… (#352)
* feat: pass resolved capabilities through to providers, add server compat layer

The LLMProvider protocol previously forced providers to re-derive
capabilities from static lookup tables, ignoring config overrides set
via the admin UI or config.toml (e.g. thinking_mode, token_param).
This adds an optional capabilities parameter to create_streaming and
create_completion so the session can pass its config-merged
ModelCapabilities through to providers.

On top of this, adds a server compatibility layer for local model
servers (vLLM, llama.cpp). Profiles suggest thinking mode and server
workarounds (skip_special_tokens for vLLM, reasoning_format for
llama.cpp) during model detection, with structured admin UI fields
for server type, thinking mode, and extra body params.

Verified against real vLLM (Gemma 4 31B) and llama.cpp (Gemma 4 E4B)
servers.

* fix: defensive copy in _finalize_extra_body, expose thinking_param in UI

Shallow-copy extra_params and its chat_template_kwargs in the provider
before _apply_thinking_mode mutates them, so callers that reuse the
same dict across models are safe.

Replace the hidden thinking_param input with a visible text field
that appears when thinking mode is enabled. Shows the default
"enable_thinking" and hints that Granite/DeepSeek use "thinking".

* fix: address Copilot review feedback on admin UI and server compat

- Preserve unrepresentable thinking_mode values (e.g. "adaptive") in
  raw capabilities JSON instead of silently dropping on edit round-trip
- Validate capabilities and extra body JSON are plain objects, not
  arrays or primitives
- Deep-merge chat_template_kwargs from extra_body instead of silently
  dropping, so operators can extend/override template kwargs

* fix: hide server compat section for non-local providers

The Server Compatibility fields (server type, thinking mode, extra
body) only apply to openai-compatible (local model servers). Hide
the entire section when the provider is openai, anthropic, or google.

* fix: normalize capsObj to plain object on edit load

Defend against DB rows where capabilities is a JSON literal null,
an array, or a primitive — previous code would crash on the
capsObj.server_compat / capsObj.thinking_mode reads. Same defensive
check also applied to the server_compat nested value.

* refactor: extract _isPlainObject helper for JSON type checks

Consolidates the null/array/typeof check that was inlined at three
different call sites into a single helper. Keeps the intent obvious
at each use site and avoids the awkward multi-condition ternary.
2026-04-14 11:05:51 -07:00
Patrick Buckley 06d7cf8896 chore: bump version to 1.4.0a1 2026-04-13 17:19:22 -07:00
Patrick Buckley 934cb075d6 feat: per-model sampling parameters (temperature, max_tokens, reasoni… (#350)
* feat: per-model sampling parameters (temperature, max_tokens, reasoning_effort)

Model sampling parameters were global-only settings applied uniformly to
all models. Different models have fundamentally different requirements
(o-series needs no temperature, Anthropic needs temp=1.0 with thinking,
local models may need different max_tokens). This adds per-model overrides
with global fallback so each model definition can specify its own defaults.

Migration 036 adds nullable temperature, max_tokens, reasoning_effort
columns to model_definitions. NULL inherits the global default from
ConfigStore. The session factory and /model switch command both resolve
per-model override → global fallback consistently.

The admin UI model create/edit modal now has dedicated form fields for
these parameters with client-side validation, a visual section divider,
and per-model override hints in the model table rows.

Removes vestigial model.name and model.context_window global settings
(now handled per-model by the model registry) with startup warnings for
existing config.toml users.

* fix: defensive parsing for config.toml per-model sampling params

Wrap temperature/max_tokens conversions in try/except with range
validation. Invalid values log a warning and fall back to None
(inherit global default) instead of aborting registry load.
2026-04-13 17:14:58 -07:00
Patrick Buckley a793d009fd fix: use gethostname() instead of getfqdn() for advertise URLs (#349)
* fix: use gethostname() instead of getfqdn() for advertise URLs

socket.getfqdn() does a reverse DNS lookup that often returns a
truncated hostname (e.g. "flat" instead of "flat-blck-io"). Use
gethostname() for advertise URLs in both server and console. For TLS
SANs, include both names so certs cover all variations.

* docs: clarify advertise URL comment re Docker/k8s
2026-04-13 14:52:12 -07:00
Patrick Buckley 2a05ba5915 fix: standardize database env vars on TURNSTONE_DB_* naming (#348)
* fix: standardize database env vars on TURNSTONE_DB_* naming

compose.yaml used DB_BACKEND/DATABASE_URL in .env which got mapped to
TURNSTONE_DB_BACKEND/TURNSTONE_DB_URL inside containers. Running bare-
metal required the TURNSTONE_ prefix, but docs didn't explain this.
Eliminate the indirection — use TURNSTONE_DB_BACKEND and TURNSTONE_DB_URL
everywhere (compose, .env, bare-metal, docs, bootstrap wizard).

* fix: update .env.example to use TURNSTONE_DB_* naming
2026-04-13 14:48:51 -07:00
Patrick Buckley cba379d994 chore: bump version to 1.3.0a3 2026-04-12 20:43:35 -07:00
Patrick Buckley 50e6e64c3d fix: universal tool_call/tool_result orphan detection for OpenAI-comp… (#346)
* fix: universal tool_call/tool_result orphan detection for OpenAI-compat providers

The Anthropic provider had orphan detection for mismatched tool_call ↔
tool_result pairs, but OpenAI-compatible providers (Chat Completions,
Google, Responses API) had none. When an Anthropic model runs behind
an OpenAI-compat API (e.g. Azure) or cancellation creates orphans,
the API rejects the malformed request.

- Rewrite sanitize_messages() with orphan detection: synthesize error
  tool results for unmatched tool_calls, drop tool results with no
  matching tool_call, fill empty tool_call IDs with positional remap
- Call sanitize_messages() from Responses API _convert_messages()

* fix: address review feedback on orphan detection

- Track answered IDs per-turn (local_answered) instead of scanning
  all of out, preventing false matches from reused IDs across turns
- Drop empty-ID tool results that have no remap entry instead of
  passing them through with invalid empty tool_call_id
- Increment empty_result_idx for every empty result, not just remapped
- Remove dead result_ids peek-ahead code
- Add test for repeated tool_call IDs across turns
2026-04-12 20:40:12 -07:00
Patrick Buckley 440e93846d fix: accurate token usage tracking for compaction across all providers (#345)
* fix: accurate token usage tracking for compaction across all providers

Anthropic's input_tokens excluded cached tokens, causing massive
under-reporting (e.g. 327 vs 9000 actual) when prompt caching was
active. This prevented auto-compaction from triggering.

- Normalize Anthropic prompt_tokens to total input (input_tokens +
  cache_creation + cache_read), matching OpenAI semantics
- Reset _last_usage per API call so tool-chain iterations get fresh
  usage instead of max()-merging with stale values
- Add mid-turn compaction check during tool chains to prevent context
  overflow before end-of-turn
- Anchor _remaining_token_budget() on provider-reported prompt_tokens
  with local estimates only for the delta since last API call
- Improve _msg_char_count() to include structural overhead (role,
  tool_call_id, tool call IDs) and handle image tokens in calibration
- Emit status after every API call, not just end of turn

* fix: defensive null coercion and index clamping from review feedback

- Add `or 0` to all getattr calls for input_tokens/output_tokens in
  Anthropic provider (streaming + non-streaming) to handle SDK nulls
- Use getattr for non-streaming input_tokens/output_tokens instead of
  direct attribute access for consistency
- Clamp _calibrated_msg_count with min() in _remaining_token_budget()
  to prevent stale state from over-slicing after compaction
2026-04-12 19:53:38 -07:00
renovate[bot] 0dd31e45ca chore(deps): update softprops/action-gh-release action to v3 (#343)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-12 19:09:16 -07:00
renovate[bot] 8c64ea0687 chore(deps): lock file maintenance (#344)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-12 18:57:03 -07:00
renovate[bot] bacb72a880 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.11.6 (#342)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-12 18:54:42 -07:00
renovate[bot] c75b66a630 chore(deps): update dependency vitest to v4.1.4 (#341)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-12 18:54:30 -07:00
renovate[bot] 6559976f2b chore(deps): update github actions (#340)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-12 18:54:19 -07:00
Patrick Buckley 83cfea36b0 chore: bump version to 1.3.0a2 2026-04-08 18:07:12 -07:00
Patrick Buckley 12516ffa04 fix(ui): remove broken hint animation and restore card toggle
The ws-check-hint animation clobbered the fadein's forwards fill,
making the checkbox invisible for 0.6s on card-body click — appearing
as a deselect-then-reselect. Remove the hint, the unused role=checkbox
on the card, and restore the original symmetric toggle behavior.
2026-04-08 18:06:54 -07:00
Patrick Buckley c33ad168c7 fix(ui): improve delete workstream UX and accessibility (#339)
* fix(ui): improve delete workstream UX and accessibility

Card body click no longer deselects (prevents confusing red border loss);
checkbox pulse hint guides users to deselect affordance. Adds keyboard
navigation, aria-labels, hover feedback, animations, and neutral Close
button styling after deletion.

* fix(ui): remove duplicate a11y checkbox from delete-mode cards

Hide the visual checkbox from the a11y tree and tab order so the card
(role=checkbox) is the sole keyboard/screen-reader target. Addresses
Copilot review feedback about nested interactive elements.
2026-04-08 17:18:59 -07:00
Patrick Buckley fd1fb7d849 chore(deps): bump lacme to >=1.0.5 (cryptography security update) 2026-04-08 16:48:06 -07:00
renovate[bot] 58b2d01b1c chore(deps): lock file maintenance (#338)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-08 16:10:27 -07:00
renovate[bot] b8440d70ac chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.11.5 (#337)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-08 16:08:22 -07:00
renovate[bot] b2206337fe chore(deps): update dependency vitest to v4.1.3 (#336)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-08 16:08:09 -07:00
renovate[bot] fadb198898 chore(deps): update pypa/gh-action-pypi-publish digest to cef2210 (#335)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-08 16:07:50 -07:00
Patrick Buckley b1e78b79fb chore: bump version to 1.3.0a1 2026-04-07 00:38:16 -07:00
Patrick Buckley 98d3289852 chore: bump version to 1.2.0 2026-04-07 00:38:05 -07:00
Patrick Buckley 2025bf8a6f perf: reduce initial rebalance from ~1.5s to ~50ms on PostgreSQL (#334)
* perf: reduce initial rebalance from ~1.5s to ~50ms on PostgreSQL

Increase seed_ring_buckets chunk sizes (PG 500→16k, SQLite 500→8k) to
cut network round-trips from 131 to 5. Add ConsoleRouter.populate_from_assignments()
to build the routing cache directly from computed assignments, eliminating the
65 536-row DB read-back. Router becomes ready in <1ms; DB persistence follows.

* fix: address review — populate after seed write, sync router version

Move router cache population after seed_ring_buckets() so the router
is never "ready" with an unpersisted ring. Pass the new rebalancer
version to populate_from_assignments() so check_version() on the
collector thread does not trigger a redundant 65 536-row refresh.
2026-04-07 00:35:55 -07:00
Patrick Buckley 100bb02e3b fix: stale ARIA attrs after promote, deferred DELETE on pre-ID dismiss
- Remove role="status" and aria-label during _promoteQueuedMessages
  so screen readers don't announce stale "queued" context
- Mark element with pendingDismiss when user dismisses before msg_id
  arrives; send deferred DELETE when the send response provides the ID
2026-04-06 22:35:23 -07:00
Patrick Buckley 2b3b229da6 fix: flush queued messages on normal completion (no tool calls)
If the model responds without tool calls, the main loop exits
immediately — no tool-result seam exists for advisory injection.
Queued messages were silently orphaned in the OrderedDict. Now
flushed as regular user messages before emitting idle state.
2026-04-06 22:34:00 -07:00
Patrick Buckley 76ecb99374 fix: queued message promote loop and dismiss behavior
Bug 1: Extract _promoteQueuedMessages() — removes badge, dismiss
button, queued classes, and data-msgId. Called from setBusy(false)
on state_change: idle.

Bug 2: _dequeueMessage no longer removes the DOM element when server
returns not_found (message already injected). Only removes on
"removed" (actually dequeued). Network errors also preserve the
element. The promote loop handles cleanup on idle instead.
2026-04-06 22:28:21 -07:00
Patrick Buckley c578051cb8 feat: tool result advisory system with user message queuing (#333)
* feat: tool result advisory system with user message queuing

General-purpose advisory injection for tool results — when advisories
are present, tool output is wrapped in <tool_output> tags with
<system-reminder> blocks appended. Two initial producers:

- Output guard advisories: model sees why content was flagged/redacted
- User message interjections: users can queue messages mid-execution
  via the web UI, injected at the next tool-call seam

Queued messages use !!! prefix for important priority. Advisory
injection is gated by ModelCapabilities.supports_tool_advisories
(default true for commercial models, false for local/vLLM).

On cancel/error, queued messages are flushed as regular user messages
so nothing is silently lost. Raw tool output (pre-wrap) is persisted
to the DB to keep history clean of ephemeral advisory XML.

* fix: frontend UX for queued messages — rollback, discoverability, a11y

- Send button changes to "Queue" (outline style) during busy state,
  visually distinct from filled red Stop button
- Placeholder updates to hint at !!! priority convention
- addQueuedMessage returns element ref for optimistic UI rollback
- Remove queued element on queue_full, busy, or connection error
- Add role="status" and aria-label to queued message elements
- Promote queued messages to normal appearance when generation ends

* feat: queued message removal via dismiss button

Switch backing store from queue.Queue to OrderedDict + Lock for O(1)
removal by ID. Each queued message gets a UUID, returned to the
frontend and stored as data-msg-id on the DOM element.

Dismiss button (x) on queued messages calls DELETE /v1/api/send with
the msg_id. If the message was already injected (race), server returns
not_found and the UI removes the element anyway.

No new endpoint — DELETE method added to the existing /v1/api/send
route. dequeue_message() on ChatSession is O(1) under the lock.

* fix: address PR review — escaping, types, list output, message cap

- Escape </tool_output> and <system-reminder> in tool output to prevent
  wrapper tag injection from untrusted tool results
- Change _collect_advisories return type from list[Any] to list[ToolAdvisory]
- Drain queued messages on list/structured output (append as text part)
  so they aren't silently stuck until a str result appears
- Cap queued message length at 2000 chars to prevent context bloat
- Remove unused var in _dequeueMessage
2026-04-06 21:51:47 -07:00
Patrick Buckley 701c3fc717 chore: bump version to 1.2.0a5 2026-04-06 15:52:05 -07:00
Patrick Buckley 92ad5bd439 Feat/tab action dropdown (#332)
* feat: replace workstream action buttons with per-tab dropdown menu

Move refresh-title, edit-title, fork, close, and delete actions from
the header toolbar into a dropdown menu on each workstream tab,
triggered by a ▾ chevron that replaces the × close button.

Dropdown follows the existing pane context menu pattern: keyboard
navigation, mutual exclusion, click-outside/Escape dismiss, toggle
on re-click, aria-expanded + aria-haspopup, and focus restoration.

Delete is visually distinct (red text + wash + red focus ring, 6px
separator). Mobile hides "Refresh title" and sizes the chevron to
36px touch targets.

Removes updateWsActionButtons(), _applyTitleButtonState(), and
_wsTitleState tracking (dead code after button removal).

* fix: remove Ctrl+Shift+R shortcut that overrides browser hard refresh

Refresh title is a low-frequency action accessible from the tab
dropdown; no replacement keybind needed.

* fix: address tab dropdown review findings

- Pass wsId through dropdown actions so they target the correct
  workstream even when opened on a non-active tab
- Fix setTimeout race where closeTabDropdown before timeout fires
  could leave stale listeners
- Guard Close and Delete on last workstream (dropdown, keyboard
  shortcuts, and defense-in-depth in confirmDeleteWorkstream)
- Use aria-disabled instead of disabled so screen reader users can
  discover unavailable items via arrow keys
- Enlarge chevron hit target, add hover affordance with subtle
  background highlight
- Add 0.1s dropdown open animation (respects prefers-reduced-motion)
2026-04-06 15:48:13 -07:00
Patrick Buckley 58c81b2b46 fix: resolve CodeQL double-import findings in test files (#331) 2026-04-06 14:18:02 -07:00
Patrick Buckley a2d4598012 fix: address CodeQL findings — BaseException and empty except (#330)
- server.py: catch (Exception, GenerationCancelled) instead of
  BaseException so KeyboardInterrupt/SystemExit propagate normally
- judge.py: log client close failures instead of bare pass
2026-04-06 13:53:26 -07:00
renovate[bot] 4f83dba1b9 chore(deps): lock file maintenance (#326)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-06 13:37:33 -07:00
dependabot[bot] 2629f217d2 chore(deps-dev): bump vite from 8.0.4 to 8.0.5 in /sdk/typescript (#329)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 8.0.4 to 8.0.5.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.5/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.0.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-06 13:14:32 -07:00
Patrick Buckley d1162b2eb9 fix: preserve Gemini thought_signature via provider_blocks fidelity lane (#328)
Gemini's OpenAI-compat endpoint requires thought_signature to survive
the tool-call round-trip. Previously dropped because the Chat Completions
provider cherry-picks only standard fields (id, type, function).

Fix: GoogleProvider now captures raw tool-call dicts (including
thought_signature) via provider_blocks — the same fidelity lane the
Anthropic provider uses for signature round-tripping. On the next turn,
_prepare_messages reconstructs tool_calls from the stored raw data and
strips _provider_content so it never reaches the wire.

Changes:
- _openai_chat.py: add _prepare_messages and _extract_tool_calls hooks
- _google.py: override hooks + tap-pattern _iter_stream for streaming
- model_registry.py: auto-detect .googleapis.com → google provider
- session.py: read cancel_on_approval from ConfigStore
- console/server.py: add PUT/DELETE to proxy route methods
- server.py: fix fork naming (don't inherit source display name)
2026-04-06 13:13:38 -07:00
Patrick Buckley 217688547e fix: expose channel gateway port for bare-metal deploys
The channel gateway registers with its Docker-internal hostname
(e.g. http://channel:8091) which is unreachable from a host-side
server. Publish port 8091 and set TURNSTONE_CHANNEL_ADVERTISE_URL
to localhost so the server can reach it for schedule notifications.
2026-04-06 10:47:50 -07:00
Patrick Buckley 5dc98f75fb fix: scheduled task notifications not delivered on cancellation
GenerationCancelled extends BaseException, not Exception, so it bypassed
the except handler in _run_initial. The finally block ran but
_extract_last_assistant_content returned "" (response never appended to
messages), and _fire_notify_targets bailed on the empty content guard.

Fixes:
- Catch BaseException (not just Exception) in _run_initial so
  GenerationCancelled is handled and the UI state is cleaned up
- Remove the empty-content suppression in _fire_notify_targets —
  scheduled tasks should always deliver, even with a fallback message
  when no output was captured
2026-04-06 09:54:03 -07:00
renovate[bot] 6980ba5aae chore(deps): lock file maintenance (#325)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-06 04:39:13 -07:00
Patrick Buckley 57912faa52 chore: bump version to 1.2.0a4 2026-04-06 03:58:42 -07:00
Patrick Buckley 0625fac87b fix: shorten judge model dropdown default label 2026-04-06 03:57:21 -07:00
Patrick Buckley dc3a1b7a64 fix: workstream toolbar UX — relocate to tab bar, fix visibility and theme sync
- Move action buttons (refresh/edit/fork/delete) from header to tab bar,
  grouped in #ws-action-group with separators. Contextually adjacent to
  the workstream tabs they operate on.
- Toggle group visibility via CSS class (.hidden) instead of per-button
  inline style.display — makes media query overrides reliable.
- Call updateWsActionButtons() from renderTabBar() so buttons appear on
  initial load and ws_created, not just on tab switch.
- Fix theme loss between nodes: loadInterfaceSettings no longer overwrites
  localStorage with server defaults — preserves user's theme choice when
  switching nodes via console proxy.
- Add flex-shrink:0 on +/split buttons to prevent squeeze with many tabs.
2026-04-06 03:54:03 -07:00
Patrick Buckley a3140da3a5 docs: update documentation for PRs #312-#316 (#324)
- README: add Google Gemini to multi-provider feature list and requirements
- architecture.md: add GoogleProvider, update supported provider values,
  file listing, config example
- judge.md: document cancel_on_approval, fresh-client lifecycle, fallback
  delivery, Google compatibility
- settings.md: add judge.cancel_on_approval, new interface.* section
  (close_tab_action, theme), update total count
- api-reference.md: document 6 new workstream/settings endpoints,
  add judge_model to workstreams/new
- console.md: add judge model to modal fields, add keyboard shortcuts
- console_schemas.py: add judge_model field to ConsoleCreateWsRequest
- server_spec.py: add 6 new EndpointSpec entries
- diagrams: add GoogleProvider to package structure and class diagram
2026-04-06 03:43:12 -07:00
Patrick Buckley 8838bd0f8d fix: apply model.default_alias on model-reload by refreshing ConfigStore
The model-reload handler read model.default_alias from ConfigStore's
in-memory cache, which could be stale if the earlier best-effort
config-reload notification failed or hadn't arrived yet. Force a
cs.reload() from DB before reading the alias. Also publish config
changes from the console before dispatching model-reload, and
downgrade the misleading "No 'default' model alias" log to debug.
2026-04-06 03:37:35 -07:00
Patrick Buckley 7f63cd2d33 feat: add keyboard shortcuts for workstream actions (#323)
Ctrl+Shift+R  Refresh title (regenerate via LLM)
Ctrl+Shift+E  Edit title
Ctrl+Shift+F  Fork workstream
Ctrl+Shift+X  Delete workstream (X not D — avoids Chrome DevTools conflict)

Shortcuts are blocked when any modal is open (edit-title, delete-ws,
batch-delete, new-ws). Help dialog (?) updated with the new bindings.
2026-04-06 03:11:06 -07:00
Patrick Buckley 24f59a6c53 feat: add per-node metadata with auto-collection, admin API, and cons… (#318)
* feat: add per-node metadata with auto-collection, admin API, and console UI

Adds a normalized node_metadata table for structured per-node key/value
metadata with source tracking (auto/user/config).  Auto-populated fields
(hostname, OS, arch, interfaces, cpu_count) are collected at server startup
via stdlib; user-defined fields are managed through the admin API, CLI, or
config.toml [metadata] section.

Storage: migration 035, 7 new protocol methods (get, get_all, set,
set_bulk, delete, delete_by_source, filter), both SQLite and PostgreSQL
backends.  Filtering uses single-query GROUP BY/HAVING for efficiency.

Console API: GET/PUT/DELETE endpoints under /admin/nodes/{node_id}/metadata
with auto-source protection.  cluster_nodes gains meta.* query param
filtering; cluster_node_detail attaches metadata to responses.

Frontend: new Nodes admin tab with collapsible per-node sections, inline
add form, delete with confirmation.  Read-only metadata panel in node
detail drill-down.  Proper design token usage, accessibility (ARIA,
keyboard nav, screen reader labels), and mobile responsiveness.

CLI: turnstone-admin list-node-metadata, set-node-metadata, and
delete-node-metadata subcommands.

64 tests (25 storage, 19 node_info, 20 existing unaffected).

* fix: resolve CI typecheck and test failures

- Fix mypy error: use %-style format string instead of structlog kwargs
  for standard Logger.warning() in console server
- Fix test_get_nodes assertion to include new node_ids=None parameter
- Add debug logging to _collect_interfaces empty except block

* fix: address Copilot review feedback on node metadata

- Clear stale auto/config metadata before upserting on startup
- Wrap metadata filter in try/except with graceful fallback
- Add metadata field to NodeDetailResponse schema
- Use _VALID_NODE_ID regex for consistent node_id validation
- Defensive JSON decode in admin_get_node_metadata
- Switch to read_json_or_400 and require_storage_or_503 helpers
- Add SetNodeMetadataValueRequest for single-key PUT endpoint
- Add bulk GET /admin/node-metadata endpoint (replaces N+1 fetches)
- Update frontend to use single bulk metadata fetch

* feat: add admin.nodes permission scope for node metadata

- Add admin.nodes to builtin-admin role via migration 035
- Switch all node metadata handlers from admin.settings to admin.nodes
- Register admin.nodes in the admin panel permission set
- Node detail metadata panel fetches from cluster endpoint (no admin
  permission needed) instead of admin endpoint

* fix: address second round of Copilot feedback

- Replace inline onclick handlers with data-* attributes and event
  delegation to prevent JS string context XSS
- Move NodeMetadataEntry before NodeDetailResponse and use it as the
  typed metadata field (was list[dict[str, Any]])
- Clean up config metadata on shutdown (was only cleaning auto)
2026-04-06 03:08:19 -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
renovate[bot] eba2f29cd1 chore(deps): lock file maintenance (#320)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-06 02:40:55 -07:00
Patrick Buckley 66c856eb6e fix: post-merge follow-ups for PRs #312-#316 (#319)
Security:
- Add write scope rules for 4 new workstream POST endpoints
  (delete, open, refresh-title, title) in required_scope() —
  both direct and console-proxied paths

Judge:
- Restore cancel_event check in inner poll loop (was removed)
- Fix fallback delivery off-by-one: items[idx+1:] not items[idx:]
- Skip empty-response retry when finish_reason=="length"
- Reset empty_retries counter after non-empty response
- Document per-turn timeout semantics in JudgeConfig

Google provider:
- Add default base_url for Gemini endpoint in create_client()
- Bump max_output_tokens 8192→65536, set token_param="max_tokens"
- Add api_key detection for googleapis.com in console detect
- Add provider badge CSS (green) and openai-compatible (dim)

Theme:
- Fix POST→PUT for settings persistence (was silently 405-ing)
- Consolidate dual localStorage keys with backwards-compat read
- Lower banner z-index 9999→200, raise login overlay to 10001
- Fix undefined --bg-input, banner contrast for WCAG AA
- Add smooth theme transition with prefers-reduced-motion override
- Console onThemeChange: add title + aria-label updates

Workstream backend:
- Restore close_workstream 400 for last-ws case (was changed to 404)
- Thread-safe _llm_verdicts via _ws_lock on all mutation sites
- Fork: persist tool_calls + provider_data in save_message
- Add get_workstream_metadata to StorageBackend protocol
- Add ChatSession.request_title_refresh() public API
- Use cs.stored_keys() instead of cs._cache
- Redact exception text in delete 500 response
- web_helpers: catch-all logs and returns 500 not 400
- Live-stream ws_created SSE includes title field

Workstream UI:
- Focus traps + Escape on edit-title and delete-ws modals
- Tab close aria-label, mobile breakpoint for action buttons
- Restore name priority (live SSE over stale API)
- Fix double-delete, fork button text, batch delete handler leak
- Optimistic title update, close-last-tab error toast
- ws_id badge show-on-hover, hover states, aria-live, emoji a11y

Console admin:
- Banner aria-labels, judge dropdown wording, detect button class
- New-ws modal Escape handler, provider defaults cross-reference
2026-04-06 02:23:00 -07:00
Patrick Buckley 40a560b39c Merge pull request #316 from sillyWillieBilly/feat/console-enhancements
feat(console): theme-aware banner, judge model support, Google provider in admin
2026-04-06 00:56:39 -07:00
Patrick Buckley bc945852f7 Merge pull request #315 from sillyWillieBilly/feat/ui-enhancements
feat: UI enhancements — workstream management, title editing, fork, delete, theme sync
2026-04-06 00:56:36 -07:00
Patrick Buckley ca70e79d43 Merge pull request #314 from sillyWillieBilly/feat/workstream-management
feat: workstream management — fork, rename, delete, open, interface settings
2026-04-06 00:56:34 -07:00
Patrick Buckley ebcfb56f0e Merge pull request #313 from sillyWillieBilly/feat/judge-improvements
feat: harden judge with fresh-client lifecycle, fallback delivery, and Google compatibility
2026-04-06 00:56:26 -07:00
Patrick Buckley 33d29e3316 Merge pull request #312 from sillyWillieBilly/feat/google-provider
feat: add Google (Gemini) provider adapter
2026-04-06 00:56:08 -07:00
renovate[bot] bfda91cd25 chore(deps): lock file maintenance (#317)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-06 00:19:40 -07:00
William 6fe9f75c3c feat(console): theme-aware banner, judge model support, Google provider in admin
- Replace inline-style console banner with CSS classes + light/dark theme
- Node ID in banner is now a clickable link back to the node UI
- Add judge_model parameter to create_workstream flow
- Add Google to model provider list with default URL
- Provider-specific placeholder hints in model editor
- Detect results populate model name suggestions datalist
- Theme changes in admin settings apply immediately
- Persist theme selection to server via settings API
- Use workstream title field (with name fallback) in collector SSE events
- Add judge model dropdown to new-workstream modal
2026-04-06 08:14:23 +02:00
William c093df274d feat: workstream management — fork, rename, delete, open, interface settings
Add workstream forking (resume with fork=True keeps new ws_id), custom
naming via aliases, title refresh via LLM, and workstream deletion.

New server endpoints: delete, refresh-title, set-title, open-workstream,
list/update interface settings.  Verdict caching with SSE replay on
reconnect, display name fallback (alias→title→name) across all
endpoints, judge_model override per workstream, and settings_changed
broadcast on config reload.

New settings: judge.cancel_on_approval, interface.close_tab_action,
interface.theme.  Storage backends updated with name in
list_workstreams_with_history and new get_workstream_metadata method.
2026-04-06 08:14:18 +02:00
William 49cdb3d0d3 feat: UI enhancements — workstream management, title editing, fork, delete, theme sync
Add workstream action buttons in header (refresh title, edit title, fork,
delete) with supporting modals and keyboard shortcuts.

Workstream tabs: always-visible close button, ws_id badge, configurable
close-tab-action (last_used/nearest/dashboard) via interface settings.

Dashboard: batch delete mode with multi-select, saved workstream cards
with ws_id badge, open endpoint for resuming sessions.

Judge display: late-arriving verdict toast when DOM element is gone,
worst-case verdict glow across all tool calls in approval block.

Theme: server-persisted via admin settings API, real-time sync across
clients via SSE settings_changed events.

New workstream modal: judge model dropdown for per-workstream judge
model selection.
2026-04-06 08:14:14 +02:00
William 04c62f90ff feat: harden judge with fresh-client lifecycle, fallback delivery, and Google compatibility
- Create fresh HTTP client per evaluation run to avoid stale connections
- Store client factory args instead of client instance for on-demand creation
- Add cancel_on_approval config: when True, abort remaining items on user
  approval; when False (default), run all evaluations to completion
- Always deliver LLM verdicts via callback (or fallback when LLM returns None)
- Add _deliver_fallbacks helper for cancelled/incomplete evaluations
- Skip read-only tools for Google provider (requires thought_signature)
- Flatten conversation history to plaintext transcript in _prepare_context
  to avoid multi-turn role sequence errors with strict providers like Google
- Use per-turn timeout instead of shared budget so slow turns don't starve
  later ones
- Add empty-response retry logic (up to 3 retries without consuming turns)
- Enhanced structured logging throughout judge pipeline
- Update tests to match new signatures and behavioral changes
2026-04-06 08:14:09 +02:00
William 1bbaf50214 feat: add Google (Gemini) provider adapter
Add GoogleProvider that extends OpenAIChatCompletionsProvider for
Gemini models via the OpenAI-compatible /v1beta/openai/ endpoint.

- New _google.py with 2M context window defaults and vision support
- Lazy-initialized singleton in create_provider() (thread-safe)
- Route 'google' through OpenAI SDK in create_client()
- Return empty list from list_known_models() (Google models change frequently)
2026-04-06 08:14:05 +02:00
renovate[bot] 38e49b6f9c chore(deps): lock file maintenance (#311)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-05 22:10:44 -07:00
Patrick Buckley 99b0e8db12 chore: bump version to 1.2.0a3 2026-04-05 18:21:22 -07:00
Patrick Buckley d22f5a4baf feat: reconcile judge admin rule UX with edit, disable, and reset act… (#310)
* feat: reconcile judge admin rule UX with edit, disable, and reset actions

Replace the misleading "Customize" button on built-in rules with a
logically consistent 4-state action model: pure built-in (Disable/Edit),
overridden built-in (Disable/Edit/Reset), disabled built-in
(Enable/Edit/Reset), and custom rule (Enable-Disable/Edit/Delete).

Add edit modals for both heuristic rules and output guard patterns,
reusing the existing create modal form structure. Introduce amber
"Reset" button styling to visually distinguish reversible resets from
permanent deletes. Fix source badge redundancy (disabled built-ins now
show grey "built-in" in SOURCE, red "disabled" in STATUS only). Add
aria-labels and role="listitem" for screen reader support.

* fix: preserve built-in pattern_flags and priority on override

Derive pattern_flags from compiled regex for built-in output guard
patterns in the list API so IGNORECASE and other flags survive the
disable/edit/override round-trip. Carry priority through edit modals
via hidden fields so built-in evaluation order is preserved.
2026-04-05 18:19:47 -07:00
renovate[bot] da5eae5352 chore(deps): update dependency katex to v0.16.45 (#309)
* chore(deps): update dependency katex to v0.16.45

* chore: download vendored JS files

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-05 17:57:43 -07:00
Patrick Buckley adb42c66da feat: deliver scheduled workstream results to Discord on completion (#308)
When a scheduled workstream finishes execution, deliver the final
assistant response to configured Discord channels/users via the
existing channel gateway notify infrastructure.

- Add notify_targets column to scheduled_tasks (migration 034)
- Add notify_targets field to Workstream dataclass
- Storage: accept/return/update notify_targets in protocol, SQLite, PostgreSQL
- Server: validate targets, extract last assistant content, deliver via
  gateway with retry, post-completion hook in _run_initial finally block
- Schedule targets override skill notify_on_complete (dedup rule)
- SDK: notify_targets param on async + sync create_workstream
- Console scheduler: pass notify_targets through dispatch
- Console server: schedule CRUD accepts/validates/returns notify_targets
- API schemas: notify_targets on schedule + workstream request/response
- Admin UI: notify textarea in schedule create/edit modals with JSON
  validation, monospace font, aria-describedby hints
- Governance UI: notify_on_complete textarea in skill create/edit with
  client-side JSON validation and field reset on create
- Bounds: max 10 targets, 256 char field limit, gateway response body
  verification matching _exec_notify pattern
- Gateway: 30s asyncio.wait_for timeout on adapter.send to prevent
  hung Discord API calls from blocking the notify endpoint indefinitely
- 39 new tests covering validation, extraction, delivery, dispatch,
  CRUD, and adapter timeout
2026-04-05 17:18:26 -07:00
Patrick Buckley 7968f1b361 feat: auto-invalidate JWT and static assets on version upgrade (#307)
* feat: auto-invalidate JWT and static assets on version upgrade

Add a `ver` claim (major.minor) to user-facing JWTs so tokens from
previous versions are rejected after upgrade, triggering re-login.
Service tokens are excluded for rolling-deployment safety. Tokens
without a `ver` claim (pre-upgrade) are accepted for backward compat.

Inject `?v={__version__}` query strings into static asset URLs at
startup so browsers fetch fresh JS/CSS after any release. Vendored
libraries (KaTeX, Highlight.js, etc.) are skipped since they already
carry version numbers in directory paths. HTML responses now include
`Cache-Control: no-cache` to ensure browsers always revalidate.

Frontend detects upgrade-specific 401s and shows a contextual subtitle
("The server was updated — please sign in again"), then performs a full
page reload after re-auth to load the new versioned assets.

* refactor: address PR review — public API name, single decode, idempotent regex

Rename _version_slot() → jwt_version_slot() to make the cross-module
import explicit rather than relying on a private name.

Move version gating from validate_jwt() into check_request() via a new
AuthResult.token_version field. This eliminates the double JWT decode
that occurred on version-mismatch detection — the token is now decoded
once and the version compared afterward.

Guard version_html() regex against double-apply by excluding URLs that
already contain a query string ([^"?]+ instead of [^"]+).

* feat: structured version_mismatch code, ETag, cross-tab auth sync

Add structured "code": "version_mismatch" field to the 401 response
so the frontend detects upgrade-triggered re-auth without string
matching on the error message.

Add ETag headers to HTML index responses (server, console, and proxied
node UI). Combined with Cache-Control: no-cache, browsers send
conditional GETs and receive 304 between upgrades, saving bandwidth.

Add BroadcastChannel-based cross-tab auth sync so logging in on one
tab dismisses the login modal on all other tabs (and vice-versa for
logout).

Add a reminder to the vendored JS update script about the
version_html() regex lookahead.

* fix: remove unused import in test_web_helpers
2026-04-05 16:25:53 -07:00
Patrick Buckley 8de53f5cc1 feat: Discord /ask model alias, channel default setting, admin UX (#306)
* feat: Discord /ask model alias, channel default setting, admin UX

Add optional 'model' parameter to Discord /ask command with
autocomplete from available aliases. Model precedence:
explicit > channels.default_model_alias > CLI --model > server default.

- Add channels.default_model_alias to settings registry
- Extend /v1/api/models response with default_alias and
  channel_default_alias fields (both server and console)
- Add list_models() to async + sync SDK clients and ChannelRouter
- TTL-cached channel default in ChannelRouter (5min, fail-open)
- @mention path also respects channel default
- Admin Settings tab: model alias settings render as dropdowns
  populated from enabled model definitions
- Admin Settings tab: is_secret settings render as write-only
  password inputs with save button (replaces static label)
- Update OpenAPI schemas for new response fields
- Validate alias defaults against enabled models on both endpoints

* fix: address PR #306 review feedback

- Move TTL timestamp update before await in get_channel_default_alias
  to prevent concurrent duplicate fetches
- Add 30s TTL cache for list_models() to avoid per-keystroke HTTP
  traffic during Discord autocomplete
- Type SDK list_models() with ListAvailableModelsResponse instead
  of raw dict (both server and console, async + sync)
2026-04-05 15:08:21 -07:00
Patrick Buckley 8808a56801 Add tavily api key to config store and change is_secret tests 2026-04-05 13:09:36 -07:00
Patrick Buckley c071236927 chore: bump version to 1.2.0a2 2026-04-05 12:43:39 -07:00
Patrick Buckley 035ccb0603 fix: remove stale JudgeConfig field references and fix font sizing
- Fix IntentJudge.__init__() control flow: model override block was
  dangling inside try/except instead of being a separate branch
- Remove provider/base_url/api_key kwargs from server.py and cli.py
  JudgeConfig construction (fields removed in prior commit)
- Remove stale TOML mapping entries from config.py
- Remove --judge-provider CLI argument
- Fix Judge settings font sizes to match Settings tab (12px keys,
  11px descriptions, tighter spacing, --fg instead of --accent)
2026-04-05 12:38:21 -07:00
Patrick Buckley 2c050b2520 refactor: remove duplicate judge provider/base_url/api_key fields
Judge model config now uses model aliases exclusively via ModelRegistry.
The separate provider, base_url, and api_key fields on JudgeConfig were
redundant with what's already stored in model definitions. Removes the
fields from JudgeConfig, the explicit-provider resolution path from
IntentJudge.__init__(), and the 3 settings from the registry.
2026-04-05 12:15:38 -07:00
Patrick Buckley 72dd7b50bd fix: authFetch, r.ok checks, model picker race, mypy Mapping type
- Replace all raw fetch() + _adminToken with authFetch() helper
- Fix URL paths to use /v1/api/admin/judge/ prefix
- Add r.ok checks on all GET fetches (match existing tab pattern)
- Load model definitions before settings to fix picker race condition
- Escape secret input values with escapeHtml
- Use Mapping type for evaluate_output patterns param (mypy)
- Clean up stale blank lines and comment references
2026-04-05 02:14:58 -07:00
Patrick Buckley d7cac3716f Worktree feat configurable output guard (#305)
* feat: configurable judge rules with dedicated admin tab

Externalize heuristic intent validation rules and output guard patterns
from hard-coded module constants into the storage abstraction with full
admin UI CRUD. Introduces a dedicated Judge tab in the admin panel that
consolidates all judge configuration (scalar settings, heuristic rules,
output guard patterns) under a single admin.judge permission scope.

- Add heuristic_rules and output_guard_patterns tables (migration 033)
- Add RuleRegistry with thread-safe merge of built-in + DB rules
- Refactor output_guard.py patterns into structured OutputGuardPatternDef
- evaluate_heuristic() and evaluate_output() accept optional rules/patterns
- IntentJudge resolves model aliases via ModelRegistry
- 15 admin API endpoints under /api/admin/judge/ with regex validation
- Judge tab with Settings, Heuristic Rules, and Output Guard sub-panels
- Filter judge.* settings from generic Settings tab
- ConfigStore.storage public property for backend access

* fix: align Judge tab with admin panel design system

- Replace raw <table> with grid-based admin-row/admin-colheaders pattern
- Replace dynamic innerHTML modals with static overlays using focus traps
- Replace confirm() with styled showConfirmModal()
- Replace inline badge styles with scope-badge classes
- Add mobile responsive breakpoints for Judge tab grids

* fix: Judge tab accessibility and polish

- Extract sub-section switcher inline styles to CSS classes
- Add focus-visible outline and reduced-motion support
- Add tab button IDs and fix aria-labelledby on tabpanels
- Add tabindex roving and arrow key navigation for sub-tabs
- Add role=list and aria-live to table containers
- Replace status text with scope-badge classes for scannability

* fix: address CodeQL and Copilot review feedback

- Remove unused validation constants from rule_registry.py (CodeQL)
- Return MappingProxyType from output_patterns for immutability
- Fix ThreadPoolExecutor shutdown(wait=False) to prevent hangs
- Use separate _VALID_OG_RISK_LEVELS (no "critical") for output guard
- Pass pattern_flags to regex validation in update endpoint
- Chain redactions in configurable mode (compose pattern + complex)
- Initialize RuleRegistry on console app.state
- Fix test fixtures to use valid enum values (approve/review/deny)

* fix: use Mapping type for evaluate_output patterns param (mypy)
2026-04-05 01:53:33 -07:00
Patrick Buckley 2b93598d68 feat: multi-model health tracking with runtime default and DB-only st… (#304)
* feat: multi-model health tracking with runtime default and DB-only startup

Replace active-probe circuit breaker with passive per-backend health
tracking.  Backends are marked degraded after consecutive failures and
recover when a request succeeds — requests are never blocked.

- Add model.default_alias ConfigStore setting for runtime default model
- Make load_model_registry CLI args optional for DB-only startup
- Per-(provider, base_url) health trackers via HealthTrackerRegistry
- Two-pass fallback: prefer healthy backends, then try degraded
- Remove BackendHealthMonitor, CircuitState, probe threads, cooldown
- Remove circuit_state from API schema, SDK events, metrics, frontends

* feat: add "Set Default" button to Model Definitions admin panel

Show a "default" badge on the current default model alias and a
"set default" action button on all other models. Clicking it writes
model.default_alias via the settings API. The list endpoint now
includes default_alias in the response so the UI can highlight it.

* fix: address review feedback — metric scoping, effective default, session alias

- Move turnstone_backend_up metric out of BackendHealthTracker into
  server callback; only the effective default backend drives the gauge
- _build_health_dict resolves effective default via ConfigStore override
- session_factory computes selected_alias once before registry.resolve
- admin model-definitions endpoint returns effective default (not just
  override) so UI shows correct badge when ConfigStore is empty
- Rename circuitTitle → healthTitle in console JS
- Fix ruff SIM117 lint in test

* fix: validate effective default against enabled models, degraded label, log normalization

- admin model-definitions endpoint validates default_alias against
  enabled models using same fallback rules as load_model_registry
- UI text "backend down" → "backend degraded" to match advisory semantics
- Health tracker log uses normalized base_url from key, not raw argument
2026-04-04 23:44:29 -07:00
Patrick Buckley 9d4d7a5346 fix: add admin.prompt_policies to valid permissions and builtin-admin role (#303)
Migration 031 created the prompt_policies table but never registered
admin.prompt_policies in _VALID_PERMISSIONS or granted it to the
builtin-admin role, causing 403 on all prompt-policy admin endpoints.
2026-04-04 22:19:03 -07:00
Patrick Buckley 0e3788a54f docs: update release tracks table for 1.1.0 stable / 1.2.0a1 experimental 2026-04-04 19:19:32 -07:00
Patrick Buckley 7f3d6c4da1 chore: bump version to 1.2.0a1 2026-04-04 19:18:53 -07:00
Patrick Buckley b30e1394e0 chore: bump version to 1.1.0 2026-04-04 19:18:22 -07:00
Patrick Buckley af0bf5270c chore: update bootstrap example version to 1.1.0 2026-04-04 19:18:08 -07:00
Patrick Buckley d100ac92d9 fix: capacity-aware tool output truncation and context overflow recovery (#301)
* fix: capacity-aware tool output truncation and context overflow recovery

Large tool results (e.g. 593K-char search output) could overflow the
context window in a single turn when the conversation was already
partially full.  The fixed 50%-of-context truncation limit didn't
account for current usage.

Changes:
- _truncate_output() now accepts remaining token budget and uses
  min(tool_truncation, remaining_budget_chars) as the effective limit
- _remaining_token_budget() helper calculates available capacity with
  reserves for max_tokens response and 5% safety margin
- Safety truncation at tool-result append: every string tool result is
  clamped to remaining budget before entering the message array
- _exec_web_search() now calls _truncate_output() (was missing)
- Context overflow recovery: catches provider errors indicating context
  length exceeded (OpenAI + Anthropic patterns), auto-compacts, retries
  once.  Falls back to original error if compact-and-retry fails.

* fix: address review — zero-budget floor, nested spinner, Anthropic patterns, tests

- Remove 256-char floor from budget truncation — zero budget now returns
  a placeholder instead of allowing 256 chars through
- Stop thinking spinner before compact to avoid nested start/stop
- Add Anthropic error patterns (prompt is too long, input tokens)
- Wrap compact-and-retry so failures re-raise the original error
- Add 15 tests covering budget calculation, capacity-aware truncation,
  and overflow recovery for both providers

* fix: cap response reservation at 25% of context window

Reserving the full max_tokens in _remaining_token_budget() zeroed the
budget for common configs like max_tokens=32768 on a 32K context,
collapsing all tool output to a placeholder.  max_tokens is a ceiling,
not guaranteed consumption — cap the reserve at context_window // 4.

Adds regression test for max_tokens >= context_window.
2026-04-04 19:11:07 -07:00
renovate[bot] 57df445224 chore(deps): lock file maintenance (#302)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-04 19:06:59 -07:00
Patrick Buckley f978e7facd fix: skip chat_template_kwargs for commercial OpenAI API (#297)
* fix: skip chat_template_kwargs for commercial OpenAI API

OpenAI rejects chat_template_kwargs as an unknown parameter — it's only
meaningful for local model servers (vLLM, llama.cpp, SGLang).

Split OpenAIProvider into separate singletons for "openai" vs
"openai-compatible" so _provider_extra_params can gate on provider_name
instead of inspecting base_url. Also deduplicates agent inline code into
the same method and fixes pre-existing test pollution where
get_capabilities was mutated on the singleton without cleanup.

* feat: add OpenAI Responses API provider for commercial models

Split the OpenAI provider into three concrete implementations behind the
LLMProvider protocol:

- _openai_chat.py: Chat Completions API for local model servers
  (vLLM, llama.cpp, SGLang)
- _openai_responses.py: Responses API for commercial OpenAI
  (GPT-5.x, O-series)
- _openai_common.py: shared capability table, temperature/reasoning
  gating, cache retention, citations, usage extraction

The Responses API handles reasoning_effort as a {"effort": value} dict,
system messages as an instructions field, and tool format translation at
the provider boundary. ChatSession is unchanged — the provider abstracts
the API difference.

Also fixes diff_file direction when comparing against provided content.

* fix: Responses API input format and local model provider routing

- Assistant input messages use plain string content (not output_text)
- Tool call argument deltas match on item_id, not call_id
- Auto-detect openai-compatible provider for non-api.openai.com URLs
- Fix diff_file direction when comparing against provided content

* fix: resolve env vars before provider auto-detection in config.toml models

Config-file model entries using ${ENV_VAR} placeholders in base_url were
not resolving env vars before _resolve_openai_provider(), causing
commercial OpenAI configs to be misclassified as openai-compatible.
2026-04-04 18:36:37 -07:00
Patrick Buckley 0872f5f5ba fix: add intent comments to intentionally-empty except blocks (#300)
Annotate 20 empty except-pass blocks with brief explanations so
CodeQL's empty-except rule recognizes them as deliberate: optional
imports, JSON parse fallback chains, SSE poll timeouts, best-effort
fetches, and defensive datetime/float parsing.
2026-04-04 18:13:24 -07:00
Patrick Buckley 205e7818f8 Fix/codeql quality findings (#299)
* fix: replace empty except blocks with diagnostic logging

Add log.debug/warning to 7 bare except-pass blocks that silenced
failures in security-relevant or operationally-important paths:
- Channel route lookup, CLI policy evaluation, OIDC JWKS fetch,
  prompt policy loading, plan file write, routing override, username
  resolution.

Plan write now reports failure to user instead of falsely claiming
"Plan saved."

* fix: replace assert-with-side-effect and narrow BaseException catch

- Convert 4 assert isinstance() to explicit TypeError raises — assertions
  are stripped under python -O, removing runtime type checks
- Narrow except BaseException to except Exception in fallback handler —
  KeyboardInterrupt/SystemExit should not record as health failures
- Plan write failure now reports error to user instead of "Plan saved"

* fix: wire up toast error type and remove useless conditional

- showToast() now accepts optional type param ("error") with red border
  styling — 3 call sites were passing "error" that was silently ignored
- Remove always-true if (q) guard after early-return on empty query

* fix: remove unreachable return None after return self._judge

* fix: parenthesize multi-line string concatenations in dev_parts list

Explicit parens make intentional concatenation unambiguous to static
analysis (CodeQL implicit-string-concatenation-in-list rule).

* fix: remove constant-true filter in test mock — return list directly

* fix: extract side-effecting calls from assert in tests

store.delete() and mgr.close() have side effects that would be
stripped under python -O. Assign to variable first, then assert.

* fix: remove unused local variables in tests

Drop assignments to unused workstream/variable references created
solely for side effects. Use _ for unused tuple unpacking.

* fix: use admin.prompt_policies permission for prompt policy endpoints

All 5 prompt-policy endpoints (list, create, get, update, delete)
were checking admin.policies (the tool-policy permission) instead of
admin.prompt_policies. This caused a mismatch with the admin UI which
gates the tab on admin.prompt_policies — users could see the tab but
get 403, or reach the endpoint but never see the tab.

* fix: use caplog instead of capsys for structlog warning assertion

structlog output goes through the logging system, not stdout/stderr.

* fix: address review — remove dead isinstance, module-level import, unnecessary lambdas

- session.py: remove unreachable isinstance check (has_batch already
  validates raw_edits is a list)
- cli.py: move logging import to module level
- test_workstream.py: replace lambda wid: FakeUI(wid) with FakeUI
2026-04-04 17:53:20 -07:00
Patrick Buckley caf449e048 fix: address code scanning alerts — URL sanitization, workflow harden… (#298)
* fix: address code scanning alerts — URL sanitization, workflow hardening, XSS

- CI workflow: add top-level permissions (contents: read)
- Docker publish: gate on head_repository == self to block fork-based pwn
- URL checks: replace substring matching with proper hostname parsing
  (eval.py, model_registry.py, console/server.py)
- renderer.js: allowlist URL schemes (http/https) for images and links
- app.js: escape backslashes before quotes in CSS selector construction

* fix: break CodeQL taint chain — normalize image URL via URL constructor

* fix: address review — scheme-less URL handling, protocol-relative rejection, data:image allowlist

- Normalize scheme-less base URLs before hostname parsing (eval, model_registry,
  console/server) so api.openai.com without https:// still matches
- Reject protocol-relative URLs (//host) in image and link allowlists
- Allow data:image/ URIs for inline MCP resource images
- Tighten image source to https:// only (no relative paths)

* fix: route data: URIs through URL constructor to break CodeQL taint chain
2026-04-04 16:52:46 -07:00
Patrick Buckley 2bfc0f2c5d fix: harden MCP client against misbehaving servers (#296)
* fix: harden MCP client against misbehaving servers

Misbehaving/failed/misconfigured MCP servers could peg CPU at 100% due
to anyio cancel-scope busy-loops (SDK #2147), uncancelled orphaned
futures, and missing application-layer resilience.

Five fixes:

1. Cancel orphaned futures on timeout — future.cancel() in all sync
   bridge methods prevents coroutine accumulation on the event loop

2. Per-server circuit breaker — 3-failure threshold with exponential
   cooldown (30s–5min), per-server jitter, auto-reconnect on half-open
   probe, McpError excluded (protocol errors from healthy servers)

3. Safe transport stream pre-close — store stream refs and close them
   before stack teardown in all error/shutdown paths, preventing the
   anyio zero-buffer CPU busy-loop

4. Notification debounce — 5s per-server rate limit on list_changed
   refresh storms from buggy servers

5. Periodic refresh backoff with auto-reconnect — disconnected servers
   get reconnection attempts with exponential backoff (60s–1hr) instead
   of being silently skipped forever

* docs: add MCP resilience section to architecture docs and diagram

Document the circuit breaker, future cancellation, stream pre-close,
notification debounce, and periodic refresh backoff in the architecture
guide and the MCP architecture PlantUML diagram.

* fix: address review — stack leak on transport error, half-open comment

- Widen _connect_one guard to check _per_server_stacks too, not just
  _sessions. Transport errors in sync dispatch methods evict the session
  but left the stack behind, leaking anyio tasks on reconnect.
- Clarify half-open design: multiple callers are intentionally allowed
  through (reconnects serialize on the event loop, first failure re-trips).
2026-04-04 16:06:42 -07:00
Patrick Buckley c67aba0127 fix: mobile UX for console sidebar drawer and server chat input (#295)
* fix: mobile UX for console sidebar drawer and server chat input

Console admin sidebar: add box-shadow elevation, close button with
focus return, 44px touch targets, focus-into-drawer on open, flip
active indicator to left border, cubic-bezier easing, aria-expanded,
fix resize handler state desync, guard toggle injection for panels
without toolbars.

Server chat input: on touch devices Enter inserts newline (tap Send
button to send), hide Shift+Enter hint from placeholder.

* fix: preserve first group label spacing when close header is injected

Add sibling combinator selector so the first sidebar group keeps its
reduced top padding regardless of whether the close header div is
present as first-child.
2026-04-04 14:52:37 -07:00
Patrick Buckley db0baefeb2 feat: render rich media embeds for MCP tool results (#292)
* feat: render rich media embeds for MCP tool results

Detect structured media JSON (stream_url, results, sessions) in MCP
tool output and render interactive cards instead of plain text.

Web UI: media cards with thumbnail, title, metadata, and click-to-play
video/audio. HLS via lazy-loaded hls.js with direct-stream preference.
Collapsed raw JSON (API keys redacted) for inspection.

Discord: rich embeds with proxied thumbnail images (fetched by the bot
since Discord CDN cannot reach private media servers). Search results
as numbered lists, session state as "Now Playing" cards. Stream URLs
never exposed in embeds — web_url used for safe clickable links.

CI: vendor hls.js 1.6.15 with renovate tracking and update script.

* fix: address PR #292 review — SSRF guards, streaming fetch, tests

- URL validation: reject non-http(s) schemes and userinfo in thumbnail
  URLs. Private IPs intentionally allowed (media servers are on LAN).
- Streaming fetch: use http.stream() with aiter_bytes() and a running
  byte count to enforce the 2MB cap without buffering the full response.
  Validate content-type is image/* before downloading.
- Resilience: wrap try_build_media_embed in try/except in bot.py so a
  media embed failure falls through to the code-block path.
- LICENSE: download hls.js LICENSE from npm on update instead of only
  copying from old dir.
- Tests: add 19 new tests — try_parse_media (8 cases), _is_safe_image_url
  (7 cases), embed builders (4 cases including stream_url exclusion and
  string season/episode safety).

* chore: add LICENSE file for vendored hls.js

* fix: remove ANSI escape codes from tool preview fields

Preview text (tool args, URLs, queries) was wrapped in DIM/RESET ANSI
codes at the source in session.py, which leaked into SSE events and
rendered as raw escape sequences in Discord and the web UI.

Move ANSI styling to the CLI consumer (cli.py) where it belongs. Also
escape markdown in Discord tool name titles to prevent __ from being
interpreted as underline formatting.

* fix: drop [MCP: server] prefix from tool descriptions

The prefix made MCP tools look second-class compared to builtins,
causing models to hesitate using them. The server name is already
encoded in the tool name (mcp__server__tool).

* feat: pretty-print JSON tool output, player error state, broader key redaction

- JSON tool results are detected and pretty-printed with 2-space indent
  instead of rendering as a wall of text
- API key redaction extended to cover api_key, apiKey, api-key, and
  token query params across all tool output (not just media embeds)
- Video/audio player shows styled error message when stream fails to
  load instead of leaving a broken player element
- Both appendToolOutput and replayHistory use shared renderToolOutput()

* fix: designer review — player error retry, contrast, tool-cmd cap

- Player error: role="alert" for screen readers, retry button that
  reuses existing play handler, includes media title in error message
- Light theme: darken --red from #dc2626 to #b91c1c (5.7:1 contrast
  on --code-bg, was 4.3:1 failing WCAG AA at 12px)
- Pretty-print collapsed raw JSON in media embeds (was missed earlier)
- Cap .tool-cmd at 120px to prevent tools with many args from making
  approval blocks disproportionately tall in history replay
- Dedicated .media-player-error class instead of reusing .tool-output

* fix: Discord tool info name matching regression, suppress deprecation warning

The escape_markdown call on tool names was stored for matching against
ToolResultEvent.name, but event.name is raw/unescaped. The escaped name
never matched, so the "Running → Done" transition silently failed and
previews disappeared from the status embed.

Fix: store raw name for matching, use escaped name only for display.

Also suppress discord.py's re.sub count deprecation warning (Python
3.13+ issue, fixed upstream).

* fix: update MCP tool description tests to match prefix removal

* fix: address PR #292 review round 2

- Retry button: handle missing span children in click handler so retry
  buttons from player error state don't throw
- Footer count: use len(lines) instead of min(len(results), 10) to
  reflect actual rendered count after char budget truncation
- Null display: use "null" instead of "None" in JS tool arg preview
- Broader redaction: also redact JSON "api_key": "..." patterns
- SSRF hardening: block loopback and link-local IPs plus cloud metadata
  hostnames in thumbnail fetch (private LAN IPs still allowed)
2026-04-04 14:47:25 -07:00
Patrick Buckley 38fc933c1d fix: bundle production compose.yaml for pipx users (#293) (#294)
* fix: bundle production compose.yaml for pipx users (#293)

Users who install via pipx don't have a git clone, so there's no
compose.yaml or Dockerfile. Bootstrap now extracts a bundled production
compose file that uses pre-built ghcr.io images instead of local builds.

- Add turnstone/deploy/compose.yaml (ghcr.io images, no build blocks,
  single-node production profile only)
- Add write_compose tool to bootstrap wizard
- Update bootstrap system prompt to check for and write compose.yaml
- Remove stale ddgCluster profile references from system prompt
- Include turnstone/deploy/*.yaml in wheel

* fix: use postgresql+psycopg:// DSN scheme in compose fallbacks

The Docker image ships psycopg3, not psycopg2, so the bare
postgresql:// scheme fails. Also clarify PG usage comment in
production compose.
2026-04-04 12:26:10 -07:00
Patrick Buckley 39b39fb79d chore: bump version to 1.1.0a3 2026-04-03 15:41:15 -07:00
Patrick Buckley 0923add7db Fix/web fetch reliability (#290)
* fix: improve web_fetch reliability — strip scripts, dynamic truncation, more tokens

- strip_html() now removes <script>, <style>, <template>, <noscript>
  element content instead of just their tags
- Truncation budget scales with context window (75% in chars, 50k floor)
  and takes from the beginning only instead of head+tail splice
- max_tokens bumped from 2000 to 8192 so thinking models don't starve
  the visible extraction answer
- reasoning_effort="low" on summarization call to avoid wasting tokens
- Empty responses and empty extractions now report as tool errors

* refactor: extract _utility_completion to fix reasoning_effort duplication

Callers previously had to pass reasoning_effort both as a direct keyword
(for commercial providers) and via _provider_extra_params (for local
model servers).  This duplication was easy to get wrong — web_fetch was
already missing the direct keyword.

_utility_completion threads it through both paths from a single call,
used by title generation, compaction, and web_fetch extraction.

* fix: disable thinking when max_tokens too small, cap extraction at 500k

_reasoning_params now returns empty dict when max_tokens can't fit a
thinking budget (e.g. title gen with max_tokens=200).  Previously
produced budget_tokens >= max_tokens which is an API error on
manual-thinking Anthropic models.

Also caps web_fetch content truncation at 500k chars — the dynamic
context-window calc was producing 3M chars on 1M-context models.

* fix: clamp utility max_tokens to model output limit, add strip_html tests

_utility_completion now clamps max_tokens to the model's advertised
max_output_tokens so small/local models don't reject 8192-token
requests.

Adds 8 tests for invisible element stripping (script, style, template,
noscript) including multiline, case-insensitive, and attribute cases.

* fix: mock get_capabilities in title retry tests for _utility_completion

_utility_completion calls _get_capabilities to clamp max_tokens.  The
existing title tests mocked _provider as a bare MagicMock, so
caps.max_output_tokens was a truthy MagicMock instead of an int.  Set
get_capabilities to return a real ModelCapabilities instance.
2026-04-03 15:36:20 -07:00
Patrick Buckley 01cec062d9 fix: share single Docker image across all compose services
Build the image once via the profileless console service and reference
it as turnstone:local from server/channel.  Prevents stale images when
users run docker compose build without --profile.
2026-04-03 15:34:38 -07:00
Patrick Buckley 830eb8ba00 fix: include prompt .md files in wheel, add wheel-completeness CI (#289) (#291)
* fix: include prompt .md files in wheel, add wheel-completeness CI (#289)

Prompt markdown files were missing from PyPI wheels since the modular
prompts refactor, causing FileNotFoundError on startup for pip-installed
users.  Add the missing include pattern and a new CI job that diffs
source-tree data files against wheel contents so omissions are caught
before merge.

* fix: sanitise ALLOW patterns in wheel-completeness check

Strip blank lines and leading whitespace from the allowlist before
passing to grep -vFxf so empty patterns cannot silently match all lines.
2026-04-03 15:32:04 -07:00
Patrick Buckley 5bbf2e65eb fix: log clean one-liner when PostgreSQL becomes unavailable (#288)
* fix: log clean one-liner when PostgreSQL becomes unavailable

Wrap all 174 connection sites in PostgreSQLBackend through a _conn()
context manager that catches OperationalError, emits a single
database.unavailable log line (with connection URL), and suppresses
repeats until the connection is restored (database.connection_restored).

* fix: add StorageUnavailableError and cover all heartbeat loops

Address review feedback:
- Separate connect-phase from execution-phase in _conn() so that
  OperationalError during caller code (e.g. BEGIN IMMEDIATE lock
  contention) is not misclassified as a connectivity failure.
- Add StorageUnavailableError exception class so callers can
  distinguish transient DB outages without redundant tracebacks.
- Apply the same _conn() wrapper to SQLiteBackend for consistency.
- Catch StorageUnavailableError in all 7 periodic loops: watch
  runner, server heartbeat, channel heartbeat, console heartbeat,
  collector discovery, rebalancer, and scheduler.
- Guard dedup flag with threading.Lock.
- Add tests for dedup logging and PostgreSQL path.
2026-04-03 13:11:59 -07:00
Patrick Buckley 4d402fea6b chore: bump version to 1.1.0a2 2026-04-02 20:30:03 -07:00
Patrick Buckley 46d14ddd86 fix: chunk IN clauses to stay within DB parameter limits (#286)
* fix: chunk IN clauses to stay within DB parameter limits

psycopg caps query parameters at 65 535 and SQLite defaults to 999.
assign_buckets, prune_workstreams, and count_skill_resources_bulk were
passing unbounded lists into single IN(...) clauses, causing
OperationalError during rebalancer runs on full-size hash rings.

Chunk sizes: 10 000 (PostgreSQL), 500 (SQLite).

* fix: deduplicate assign_buckets input, add chunking regression tests

Address review feedback: deduplicate bucket list before chunking to
prevent inflated rowcount from cross-chunk duplicates. Add tests that
exercise the multi-chunk path (1200 buckets > SQLite chunk_size of 500)
and verify dedup preserves accurate counts.
2026-04-02 19:25:57 -07:00
renovate[bot] 7c4157f78d chore(deps): lock file maintenance (#285)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-02 17:28:00 -07:00
renovate[bot] 3856d80709 chore(deps): update github actions (#284)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-02 17:27:29 -07:00
Patrick Buckley 8142d2f1ad fix: add concurrency groups to publish workflows
Multiple CI completions for the same commit (tag push + branch push)
caused duplicate publish and docker runs. Concurrency group keyed on
head_sha ensures only one publish runs per commit.
2026-04-02 17:23:01 -07:00
Patrick Buckley c234d66ebf chore: bump version to 1.1.0a1 2026-04-02 17:10:06 -07:00
399 changed files with 104903 additions and 12660 deletions
+2 -2
View File
@@ -20,10 +20,10 @@ TURNSTONE_JWT_SECRET=changeme-to-32-bytes-of-hex
# -- Database ------------------------------------------------------------------
# Single-node default is SQLite (zero config). Set these for PostgreSQL:
# DB_BACKEND=postgresql
# TURNSTONE_DB_BACKEND=postgresql
# POSTGRES_USER=turnstone
# POSTGRES_PASSWORD=changeme
# DATABASE_URL=postgresql+psycopg://turnstone:changeme@postgres:5432/turnstone
# TURNSTONE_DB_URL=postgresql+psycopg://turnstone:changeme@postgres:5432/turnstone
# -- Ports ---------------------------------------------------------------------
# SERVER_PORT=8080
+9 -1
View File
@@ -41,6 +41,14 @@
"matchStrings": ["mermaid-(?<currentValue>[\\d.]+)/"],
"depNameTemplate": "mermaid",
"datasourceTemplate": "npm"
},
{
"customType": "regex",
"description": "Track vendored hls.js version",
"managerFilePatterns": ["/pyproject\\.toml$/"],
"matchStrings": ["hls-(?<currentValue>[\\d.]+)/"],
"depNameTemplate": "hls.js",
"datasourceTemplate": "npm"
}
],
"packageRules": [
@@ -91,7 +99,7 @@
{
"description": "Vendored JS — CI workflow downloads files automatically",
"groupName": "Vendored JS",
"matchPackageNames": ["katex", "highlight.js", "mermaid"],
"matchPackageNames": ["katex", "highlight.js", "mermaid", "hls.js"],
"schedule": ["before 9am on the first day of the month"],
"automerge": false
},
+17 -4
View File
@@ -7,6 +7,9 @@ on:
pull_request:
branches: [main, "stable/*"]
permissions:
contents: read
jobs:
lint:
runs-on: ubuntu-latest
@@ -40,9 +43,16 @@ jobs:
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: ${{ matrix.python-version }}
# Node is required by tests/test_renderer_js.py — without
# explicit setup, that suite silently skips if the runner
# image happens not to ship Node, masking regressions in
# the browser-side renderer.
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: "20"
- run: pip install -e ".[test]"
- run: pytest tests/ -m "not live" --cov=turnstone --cov-report=term-missing --cov-report=xml -q
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: always()
with:
name: coverage-${{ matrix.python-version }}
@@ -69,6 +79,9 @@ jobs:
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.14"
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: "20"
- run: pip install -e ".[test,postgres]"
- run: pytest tests/ -m "not live" --storage-backend=postgresql -q
env:
@@ -125,7 +138,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
- uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
uv-version: "0.9.18"
- run: uv lock --check
@@ -134,7 +147,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
- uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
uv-version: "0.9.18"
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
@@ -152,7 +165,7 @@ jobs:
working-directory: sdk/typescript
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: "24"
- run: npm ci
+6 -4
View File
@@ -19,7 +19,9 @@ env:
jobs:
docker:
if: github.event.workflow_run.conclusion == 'success'
if: >-
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_repository.full_name == github.repository
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
@@ -41,7 +43,7 @@ jobs:
- name: Log in to GHCR
if: steps.tag.outputs.skip == 'false'
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -65,12 +67,12 @@ jobs:
fi
echo "tags=${TAGS}" >> "$GITHUB_OUTPUT"
- uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
if: steps.tag.outputs.skip == 'false'
- name: Build and push
if: steps.tag.outputs.skip == 'false'
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
with:
context: .
push: true
-22
View File
@@ -1,22 +0,0 @@
name: Docker Security Scan
on:
push:
branches: [main, "stable/*"]
schedule:
- cron: "0 6 * * 1" # Weekly Monday 06:00 UTC
permissions:
contents: read
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- run: docker build -t turnstone:scan .
- uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # 0.35.0
with:
image-ref: "turnstone:scan"
severity: "HIGH,CRITICAL"
exit-code: "1"
+2 -2
View File
@@ -44,12 +44,12 @@ jobs:
if: steps.tag.outputs.skip == 'false'
- run: python -m build
if: steps.tag.outputs.skip == 'false'
- uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # release/v1
- uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
if: steps.tag.outputs.skip == 'false'
- name: Create GitHub Release
if: steps.tag.outputs.skip == 'false'
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3
with:
tag_name: ${{ steps.tag.outputs.tag }}
generate_release_notes: true
+1 -1
View File
@@ -48,7 +48,7 @@ jobs:
id: detect
run: |
updates=()
for lib in katex hljs mermaid; do
for lib in katex hljs mermaid hls; do
version=$(grep -oE "${lib}-[0-9.]+" pyproject.toml | head -1 | sed "s/${lib}-//")
[[ -z "$version" ]] && continue
[[ -d "turnstone/shared_static/${lib}-${version}" ]] && continue
+2
View File
@@ -21,3 +21,5 @@ PROGRESS.md
.coverage
tools/skill_audit_analysis/data/
tools/skill_audit_analysis/output/
design_ideas/
.claude/
-40
View File
@@ -1,40 +0,0 @@
# libexpat integer overflow — no fix available in Debian repos yet
# https://avd.aquasec.com/nvd/cve-2026-25210
# Review: remove this entry once a patched libexpat1 is published
CVE-2026-25210
# ncurses buffer overflow — no fix in Debian 13 repos yet
# Affects libncursesw6, libtinfo6, ncurses-base, ncurses-bin
# https://avd.aquasec.com/nvd/cve-2025-69720
CVE-2025-69720
# nghttp2 DoS via malformed HTTP/2 frames — no fix in Debian 13 repos yet
# Affects libnghttp2-14
# https://avd.aquasec.com/nvd/cve-2026-27135
CVE-2026-27135
# systemd arbitrary code execution via spurious IPC — no fix in Debian 13 repos yet
# Affects libsystemd0, libudev1
# https://avd.aquasec.com/nvd/cve-2026-29111
CVE-2026-29111
# glibc iconv() DoS — fix_deferred, no patched libc in Debian 13 yet
# Affects libc-bin, libc6
# https://avd.aquasec.com/nvd/cve-2026-4046
CVE-2026-4046
# minimatch ReDoS — transitive npm dep (MCP server), no direct exposure
# https://avd.aquasec.com/nvd/cve-2026-27903
CVE-2026-27903
# https://avd.aquasec.com/nvd/cve-2026-27904
CVE-2026-27904
# picomatch ReDoS — transitive npm dep, no direct exposure
# https://avd.aquasec.com/nvd/cve-2026-33671
CVE-2026-33671
# node-tar path traversal — transitive npm dep, not used to extract untrusted archives
# https://avd.aquasec.com/nvd/cve-2026-29786
CVE-2026-29786
# https://avd.aquasec.com/nvd/cve-2026-31802
CVE-2026-31802
+1255
View File
File diff suppressed because it is too large Load Diff
+9 -1
View File
@@ -26,7 +26,15 @@ transferring ownership.
```
python -m venv .venv
source .venv/bin/activate
pip install -e ".[test]"
pip install -e ".[test,dev]"
```
The `dev` extra installs `ruff` and `mypy`. Before pushing, run:
```
ruff check turnstone tests
mypy turnstone
pytest
```
## Guidelines
+1 -1
View File
@@ -8,7 +8,7 @@ FROM python:3.14-slim
LABEL org.opencontainers.image.title="turnstone" \
org.opencontainers.image.description="Multi-node AI orchestration platform"
COPY --from=ghcr.io/astral-sh/uv:0.11.3 /uv /usr/local/bin/uv
COPY --from=ghcr.io/astral-sh/uv:0.11.7 /uv /usr/local/bin/uv
# Remove the slim image's man page exclusion so man-db has actual content
RUN rm -f /etc/dpkg/dpkg.cfg.d/docker
+4 -4
View File
@@ -55,7 +55,7 @@ The wizard supports two deployment modes:
```
$ turnstone-bootstrap
Turnstone Bootstrap Wizard v0.5.4
Turnstone Bootstrap Wizard v1.5.0
────────────────────────────────────────────────
Which provider for this wizard?
@@ -87,6 +87,6 @@ $ turnstone-bootstrap
## See Also
- [Docker Deployment](docker.md) — manual compose setup and profiles
- [Security](security.md) — auth architecture and token types
- [Governance](governance.md) — roles, policies, and templates
- [Docker Deployment](docs/docker.md) — manual compose setup and profiles
- [Security](docs/security.md) — auth architecture and token types
- [Governance](docs/governance.md) — roles, policies, and templates
+17 -8
View File
@@ -8,7 +8,7 @@
Multi-node AI orchestration platform. Deploy tool-using AI agents across a cluster of servers with direct HTTP routing, interactive interfaces, and enterprise governance.
<p align="center">
<img src="docs/assets/hero.png" alt="Turnstone console — multi-workstream AI orchestration with mermaid diagrams" width="960"/>
<img src="docs/assets/hero.png" alt="Turnstone coordinator — parallel tool batches with judge-graded approval and child workstream tracking" width="960"/>
</p>
Named after the [Ruddy Turnstone](https://en.wikipedia.org/wiki/Ruddy_turnstone) (*Arenaria interpres*) — a shorebird that flips stones to discover what's hiding underneath.
@@ -30,7 +30,7 @@ Turnstone gives LLMs tools — shell, files, search, web, planning — and orche
- **Cluster dashboard** — real-time view of all nodes and workstreams with console routing proxy
- **Intent validation** — LLM judge evaluates every tool call with risk assessments and evidence
- **Governance** — RBAC, OIDC SSO, tool policies, skills, usage tracking, audit logs
- **Multi-provider** — OpenAI-compatible APIs (vLLM, llama.cpp, NIM) and Anthropic Messages API
- **Multi-provider** — OpenAI-compatible APIs (vLLM, llama.cpp, NIM), Anthropic Messages API, and Google Gemini
- **MCP support** — external tool servers with native deferred loading (Anthropic/OpenAI) or BM25 fallback
<p align="center">
@@ -53,6 +53,15 @@ pip install turnstone[console]
turnstone-console --port 8090
```
For PostgreSQL (recommended for production):
```bash
pip install turnstone[postgres]
export TURNSTONE_DB_BACKEND=postgresql
export TURNSTONE_DB_URL="postgresql+psycopg://user:pass@localhost:5432/turnstone"
turnstone-server --port 8080 --base-url http://localhost:8000/v1
```
### Docker
```bash
@@ -75,20 +84,20 @@ with TurnstoneServer("http://localhost:8080", token="tok_xxx") as client:
## Tools
Built-in tools for shell, files, search, web, memory, notifications, and autonomous sub-agents — plus external tools via [MCP](https://modelcontextprotocol.io/) with native deferred loading. See [docs/tools.md](docs/tools.md) for the full reference and [docs/mcp.md](docs/mcp.md) for MCP configuration.
Built-in tools for shell, files, search, web, memory, notifications, and autonomous sub-agents — plus external tools via [MCP](https://modelcontextprotocol.io/) with native deferred loading. See [docs/tools.md](docs/tools.md) for the full reference and [docs/mcp-registry.md](docs/mcp-registry.md) for MCP configuration.
## Architecture
**Single-node**: Client → Server (direct HTTP + SSE). No external dependencies beyond the database.
**Multi-node**: Client → Console (hash ring routing proxy) → Server nodes. The console maintains a 65536-entry bucket cache for O(1) workstream routing. A rebalancer daemon redistributes buckets when nodes join or leave.
**Multi-node**: Client → Console (rendezvous routing proxy) → Server nodes. The console picks the target node for each workstream via rendezvous (HRW) hashing over the live service registry — pure function of `(ws_id, live_nodes)`, no stored bucket state, deterministic across readers. A node join or drop only re-routes the keys that score highest on the affected node.
| Component | Purpose |
|-----------|---------|
| `turnstone` | Terminal CLI (REPL) |
| `turnstone-server` | Web UI + REST API + SSE events |
| `turnstone-console` | Cluster dashboard + routing proxy + admin panel |
| `turnstone-channel` | Channel gateway (Discord, with adapters for Slack/Teams planned) |
| `turnstone-channel` | Channel gateway (Discord and Slack adapters) |
| `turnstone-admin` | User/token management CLI |
| `turnstone-eval` | Eval harness for prompt/tool optimization |
| `turnstone-bootstrap` | LLM-guided setup wizard |
@@ -108,7 +117,7 @@ UML diagrams in [`docs/diagrams/`](docs/diagrams/):
| [Console Data Flow](docs/diagrams/png/11-console-data-flow.png) | Dashboard data collection |
| [Deployment](docs/diagrams/png/12-deployment.png) | Docker Compose topology |
| [Auth](docs/diagrams/png/15-auth-architecture.png) | JWT, scopes, login flows |
| [Channels](docs/diagrams/png/16-channel-architecture.png) | Discord adapter + routing |
| [Channels](docs/diagrams/png/16-channel-architecture.png) | Discord / Slack adapters + routing |
| [Judge](docs/diagrams/png/22-judge-architecture.png) | Intent validation pipeline |
| [OIDC](docs/diagrams/png/25-oidc-architecture.png) | SSO authorization code flow |
@@ -127,12 +136,12 @@ UML diagrams in [`docs/diagrams/`](docs/diagrams/):
| Console dashboard | [docs/console.md](docs/console.md) |
| Eval harness | [docs/eval.md](docs/eval.md) |
| Tools reference | [docs/tools.md](docs/tools.md) |
| MCP integration | [docs/mcp.md](docs/mcp.md) |
| MCP integration | [docs/mcp-registry.md](docs/mcp-registry.md) |
## Requirements
- Python 3.11+
- An OpenAI-compatible API endpoint or Anthropic API key
- An OpenAI-compatible API endpoint, Anthropic API key, or Google Gemini API key
- Optional: PostgreSQL (`pip install turnstone[postgres]`), Anthropic (`pip install turnstone[anthropic]`)
- [Git LFS](https://git-lfs.com/) for cloning (diagram PNGs)
+19
View File
@@ -95,3 +95,22 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================================================
hls.js 1.6.15
https://github.com/video-dev/hls.js
Copyright 2017 Dailymotion
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+18 -13
View File
@@ -1,10 +1,15 @@
# =============================================================================
# Turnstone Docker Compose Stack
# Turnstone Docker Compose Stack — Development
#
# This file is for local development from a git clone. It builds images
# locally from the Dockerfile. If you installed via pip/pipx, run
# `turnstone-bootstrap` instead — it writes a production compose.yaml
# that pulls pre-built images from ghcr.io.
#
# Usage:
# Infra only: docker compose up
# Single node: docker compose --profile production up
# Production (PG): DB_BACKEND=postgresql docker compose --profile production up
# Production (PG): TURNSTONE_DB_BACKEND=postgresql docker compose --profile production up
# 10-node cluster: docker compose --profile cluster up
# =============================================================================
@@ -52,8 +57,8 @@ services:
deploy:
resources:
limits:
memory: 1G
cpus: '1.0'
memory: 4G
cpus: '4.0'
restart: unless-stopped
# -------------------------------------------------------------------
@@ -89,8 +94,8 @@ services:
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:?Set TURNSTONE_JWT_SECRET in .env}
- MODEL=${MODEL:-}
- MCP_CONFIG=${MCP_CONFIG:-}
- TURNSTONE_DB_BACKEND=${DB_BACKEND:-sqlite}
- TURNSTONE_DB_URL=${DATABASE_URL:-}
- TURNSTONE_DB_BACKEND=${TURNSTONE_DB_BACKEND:-sqlite}
- TURNSTONE_DB_URL=${TURNSTONE_DB_URL:-}
- TURNSTONE_NODE_ID=${TURNSTONE_NODE_ID:-}
- TURNSTONE_ADVERTISE_URL=${TURNSTONE_ADVERTISE_URL:-http://server:8080}
extra_hosts:
@@ -126,8 +131,8 @@ services:
environment:
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:?Set TURNSTONE_JWT_SECRET in .env}
- TURNSTONE_DB_BACKEND=${DB_BACKEND:-sqlite}
- TURNSTONE_DB_URL=${DATABASE_URL:-}
- TURNSTONE_DB_BACKEND=${TURNSTONE_DB_BACKEND:-sqlite}
- TURNSTONE_DB_URL=${TURNSTONE_DB_URL:-}
- TURNSTONE_CONSOLE_URL=http://console:8090
networks:
- turnstone-net
@@ -160,8 +165,8 @@ services:
- TURNSTONE_DISCORD_GUILD=${TURNSTONE_DISCORD_GUILD:-0}
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:?Set TURNSTONE_JWT_SECRET in .env}
- TURNSTONE_DB_BACKEND=${DB_BACKEND:-postgresql}
- TURNSTONE_DB_URL=${DATABASE_URL:-postgresql://${POSTGRES_USER:-turnstone}:${POSTGRES_PASSWORD:-turnstone}@postgres:5432/turnstone}
- TURNSTONE_DB_BACKEND=${TURNSTONE_DB_BACKEND:-postgresql}
- TURNSTONE_DB_URL=${TURNSTONE_DB_URL:-postgresql+psycopg://${POSTGRES_USER:-turnstone}:${POSTGRES_PASSWORD:-turnstone}@postgres:5432/turnstone}
- TURNSTONE_CHANNEL_ADVERTISE_URL=http://channel:8091
networks:
- turnstone-net
@@ -210,8 +215,8 @@ services:
TURNSTONE_JWT_SECRET: ${TURNSTONE_JWT_SECRET:?Set TURNSTONE_JWT_SECRET in .env}
MODEL: ${MODEL:-}
MCP_CONFIG: ${MCP_CONFIG:-}
TURNSTONE_DB_BACKEND: ${DB_BACKEND:-postgresql}
TURNSTONE_DB_URL: ${DATABASE_URL:-postgresql://${POSTGRES_USER:-turnstone}:${POSTGRES_PASSWORD:?}@postgres:5432/turnstone}
TURNSTONE_DB_BACKEND: ${TURNSTONE_DB_BACKEND:-postgresql}
TURNSTONE_DB_URL: ${TURNSTONE_DB_URL:-postgresql+psycopg://${POSTGRES_USER:-turnstone}:${POSTGRES_PASSWORD:?}@postgres:5432/turnstone}
TURNSTONE_NODE_ID: node-1
TURNSTONE_ADVERTISE_URL: http://server-1:8080
extra_hosts: ["host.docker.internal:host-gateway"]
@@ -226,7 +231,7 @@ services:
start_period: 60s
deploy:
resources:
limits: { memory: 384M, cpus: '0.5' }
limits: { memory: 4G, cpus: '4' }
restart: unless-stopped
server-2:
+40
View File
@@ -0,0 +1,40 @@
# Bare-metal overlay — expose PostgreSQL and let the console reach
# a turnstone-server running outside Docker on the host machine.
#
# Requires TURNSTONE_HOST_IP set to the host's routable IP address.
#
# Usage:
# export TURNSTONE_HOST_IP="$(hostname -I | awk '{print $1}')"
# docker compose --profile production \
# -f compose.yaml -f deploy/docker-compose.bare-metal.yml up
#
# Then on the host:
# export TURNSTONE_JWT_SECRET="<same as .env>"
# export TURNSTONE_DB_BACKEND=postgresql
# export TURNSTONE_DB_URL="postgresql://turnstone:<pw>@localhost:5432/turnstone"
# export TURNSTONE_NODE_ID="bare-metal-1"
# export TURNSTONE_ADVERTISE_URL="http://${TURNSTONE_HOST_IP}:8080"
# python -m turnstone.server --host 0.0.0.0 --port 8080 \
# --base-url http://localhost:8000/v1 --api-key "$OPENAI_API_KEY"
services:
postgres:
ports:
- "${POSTGRES_PORT:-5432}:5432"
console:
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
# Console needs to reach the bare-metal server on the host
TURNSTONE_SERVER_URL: "http://${TURNSTONE_HOST_IP}:${SERVER_PORT:-8080}"
channel:
ports:
- "${CHANNEL_PORT:-8091}:8091"
environment:
# Channel gateway advertises with host-routable IP so the
# bare-metal server can reach it for schedule notifications
TURNSTONE_CHANNEL_ADVERTISE_URL: "http://${TURNSTONE_HOST_IP}:${CHANNEL_PORT:-8091}"
# Channel needs to reach the bare-metal server on the host
TURNSTONE_SERVER_URL: "http://${TURNSTONE_HOST_IP}:${SERVER_PORT:-8080}"
+1 -1
View File
@@ -7,6 +7,6 @@ appVersion: "0.3.0"
dependencies:
- name: postgresql
version: ~18.5.0
version: ~18.6.0
repository: https://charts.bitnami.com/bitnami
condition: postgresql.enabled
+321 -36
View File
@@ -229,12 +229,12 @@ below.
---
### `GET /v1/api/events?ws_id=<id>`
### `GET /v1/api/workstreams/{ws_id}/events`
Opens a Server-Sent Events stream scoped to a single workstream. The connection
remains open indefinitely; the server pushes events as they occur.
**Query parameters:**
**Path parameters:**
| Parameter | Type | Required | Description |
|-----------|--------|----------|----------------------------|
@@ -346,7 +346,7 @@ action required).
```
**`approve_request`** -- one or more tool calls that require user approval. The
client must respond via `POST /v1/api/approve`.
client must respond via `POST /v1/api/workstreams/{ws_id}/approve`.
```json
{
@@ -450,7 +450,7 @@ after `/clear` or `/new` commands).
```
**`cancelled`** -- a cancel request was acknowledged (via the Stop button or
`POST /v1/api/cancel`). This signals that cancellation is in progress, not
`POST /v1/api/workstreams/{ws_id}/cancel`). This signals that cancellation is in progress, not
that it is complete. The worker thread may still be finishing — wait for
`stream_end` before transitioning to a ready state. The client should clear
any in-progress assistant rendering but not re-enable the send button until
@@ -558,7 +558,7 @@ Possible `state` values:
and copies each event to every client queue. If a client queue is full, the
event is silently dropped for that client.
**Keepalive:** Same as `/v1/api/events` -- an SSE comment every 5 seconds.
**Keepalive:** Same as `/v1/api/workstreams/{ws_id}/events` -- an SSE comment every 5 seconds.
---
@@ -571,8 +571,8 @@ Returns a list of all active workstreams.
```json
{
"workstreams": [
{"id": "abc123", "name": "default", "state": "idle"},
{"id": "def456", "name": "hacker-news", "state": "thinking"}
{"ws_id": "abc123", "name": "default", "state": "idle"},
{"ws_id": "def456", "name": "hacker-news", "state": "thinking"}
]
}
```
@@ -581,7 +581,7 @@ Each workstream object:
| Field | Type | Description |
|--------------|-------------|--------------------------------------------------------|
| `id` | string | Unique workstream routing identifier |
| `ws_id` | string | Unique workstream routing identifier |
| `name` | string | Display name (alias if set, otherwise `ws-xxxx`) |
| `state` | string | Current state (see state values above) |
@@ -654,21 +654,26 @@ Each skill summary:
---
### `POST /v1/api/send`
### `POST /v1/api/workstreams/{ws_id}/send`
Sends a user message to a workstream. Spawns a daemon worker thread that calls
`session.send()` and streams results back via the SSE channel.
**Path parameters:**
| Parameter | Type | Required | Description |
|-----------|--------|----------|----------------------|
| `ws_id` | string | yes | Target workstream ID |
**Request body:**
```json
{"message": "Explain how the server works", "ws_id": "abc123"}
{"message": "Explain how the server works"}
```
| Field | Type | Required | Description |
|-----------|--------|----------|-------------------------|
| `message` | string | yes | The user's message text |
| `ws_id` | string | yes | Target workstream ID |
**Response (success):**
@@ -692,15 +697,21 @@ from a previous request. Also pushes a `busy_error` event to the SSE stream.
---
### `POST /v1/api/approve`
### `POST /v1/api/workstreams/{ws_id}/approve`
Responds to a tool approval request. The SSE stream must have previously sent
an `approve_request` event for the given workstream.
**Path parameters:**
| Parameter | Type | Required | Description |
|-----------|--------|----------|----------------------|
| `ws_id` | string | yes | Target workstream ID |
**Request body:**
```json
{"approved": true, "feedback": null, "always": false, "ws_id": "abc123"}
{"approved": true, "feedback": null, "always": false}
```
| Field | Type | Required | Description |
@@ -708,7 +719,6 @@ an `approve_request` event for the given workstream.
| `approved` | bool | yes | `true` to approve, `false` to deny |
| `feedback` | string/null | no | Optional feedback text (sent as denial reason) |
| `always` | bool | no | If `true` and `approved`, enables auto-approve |
| `ws_id` | string | yes | Target workstream ID |
When `always` is `true` and `approved` is `true`, the workstream's WebUI
instance sets `auto_approve = True`, causing all subsequent tool calls to be
@@ -789,7 +799,7 @@ containing the resumed session's messages.
---
### `POST /v1/api/cancel`
### `POST /v1/api/workstreams/{ws_id}/cancel`
Cancels the active generation in a workstream. Sets a cooperative cancellation
flag that is checked at multiple points in the generation loop (per streaming
@@ -812,15 +822,20 @@ for the orphaned thread. Use force cancel when cooperative cancel has not
resolved within a few seconds — the web UI offers this as a "Force Stop"
button automatically.
**Path parameters:**
| Parameter | Type | Required | Description |
|-----------|--------|----------|----------------------|
| `ws_id` | string | yes | Target workstream ID |
**Request body:**
```json
{"ws_id": "abc123", "force": false}
{"force": false}
```
| Field | Type | Required | Description |
|--------|--------|----------|----------------------|
| `ws_id`| string | yes | Target workstream ID |
| `force`| bool | no | Abandon stuck worker immediately (default: `false`) |
**Response:**
@@ -842,6 +857,15 @@ button automatically.
Creates a new workstream. The server supports up to 10 concurrent workstreams.
The endpoint accepts **either** `application/json` (legacy shape) **or**
`multipart/form-data` when you want to upload attachments at creation
time. Multipart requests carry one `meta` field containing the JSON body
shown below plus zero-or-more `file` parts; each file is validated and
reserved onto the new workstream's first turn before the dispatch worker
runs, so queued multimodal turns cannot lose files to racing sends. If
validation fails the fresh workstream is rolled back so no orphan rows
leak.
**Request body:**
```json
@@ -857,6 +881,7 @@ All fields are optional. The body can be empty or an empty JSON object.
| `auto_approve` | bool | false | Auto-approve all tool calls for this workstream |
| `resume_ws` | string | "" | Workstream ID to resume atomically during creation (empty = fresh)|
| `skill` | string | "" | Skill name. Applies content (system prompt), model, temperature, reasoning effort, max tokens, auto-approve policy, token budget, and other session config from the skill. Returns 400 if not found or disabled. Ignored when `resume_ws` is set (resumed sessions restore their own skill). |
| `judge_model` | string | "" | Optional model alias for the judge (overrides default judge model for this workstream) |
> **Skill behavior:** When `skill` is specified, the skill's content is injected as a system message and its session config fields (model, temperature, auto-approve, token budget, etc.) override system defaults for the new workstream.
@@ -883,20 +908,32 @@ Status code: `400`
---
### `POST /v1/api/workstreams/close`
### `POST /v1/api/workstreams/{ws_id}/close`
Closes and removes a workstream. The last remaining workstream cannot be
closed.
**Path parameters:**
| Parameter | Type | Required | Description |
|-----------|--------|----------|------------------------|
| `ws_id` | string | yes | Workstream ID to close |
**Request body:**
```json
{"ws_id": "abc123"}
```
The body must be valid JSON. If you are not supplying any optional
fields, send `{}` — an empty / non-JSON body is rejected with a
`400`.
| Field | Type | Required | Description |
|---------|--------|----------|---------------------------|
| `ws_id` | string | yes | Workstream ID to close |
| Field | Type | Required | Description |
|----------|--------|----------|----------------------------------------------------------|
| `reason` | string | no | Optional close reason persisted to `workstream_config`. |
The `reason` is capped at **512 UTF-8 bytes** (multibyte-safe — the
cap holds for CJK and emoji payloads), and the output guard's
credential-redaction pass strips secrets before the value is
persisted. A non-string `reason` is silently coerced to empty and
the close proceeds without writing the field.
**Response (success):**
@@ -914,6 +951,255 @@ Status code: `400`
---
### `POST /v1/api/workstreams/{ws_id}/attachments`
Upload an image or text document and attach it to the caller's next user
turn on this workstream.
- Images (png/jpeg/gif/webp) are capped at **4 MiB** and validated via
magic-byte sniff on upload.
- Text documents (any `text/*` MIME, allow-listed application MIMEs, or
known text extensions) are capped at **512 KiB** and must be UTF-8.
- Per-(workstream, user) pending cap is **10** attachments.
The attachment moves through three states: `pending → reserved →
consumed`. Reservation tokens are threaded through
`POST /v1/api/workstreams/{ws_id}/send` so a queued multimodal turn cannot lose its file to
an overlapping send.
Ownership failures are masked as `404` so non-owners cannot enumerate
workstream existence.
**Content-Type:** `multipart/form-data` with a single `file` field.
**Response (success):** `200`
```json
{
"attachment_id": "att_abc123",
"kind": "image",
"mime_type": "image/png",
"size_bytes": 73240,
"filename": "screenshot.png",
"state": "pending"
}
```
**Errors:**
| Code | Meaning |
|------|---------------------------------------------------------|
| 400 | Missing/invalid form, unsupported MIME, not UTF-8, etc. |
| 403 | Auth/scope failure |
| 404 | Workstream not found / not owned by caller |
| 409 | Pending-cap reached |
| 413 | Payload exceeds size cap |
---
### `GET /v1/api/workstreams/{ws_id}/attachments`
List the caller's **pending** (unconsumed) attachments for this
workstream. Ownership failures are masked as `404`.
**Response:** `200`
```json
{
"attachments": [
{
"attachment_id": "att_abc123",
"kind": "image",
"mime_type": "image/png",
"size_bytes": 73240,
"filename": "screenshot.png",
"state": "pending"
}
]
}
```
---
### `GET /v1/api/workstreams/{ws_id}/attachments/{attachment_id}/content`
Returns the raw bytes of an attachment with its stored `Content-Type`.
Useful for previewing an image or replaying a document. Ownership
failures are masked as `404`.
**Response:** `200` — binary body, original `Content-Type`.
---
### `DELETE /v1/api/workstreams/{ws_id}/attachments/{attachment_id}`
Remove a pending attachment. Consumed attachments return `404` (they
are part of a committed conversation turn). Ownership failures are also
masked as `404`.
**Response:** `200`
```json
{"deleted": "att_abc123"}
```
---
### `POST /v1/api/workstreams/{ws_id}/delete`
Permanently delete a saved workstream and all its messages from storage.
**Path parameters:**
| Parameter | Type | Description |
|-----------|--------|----------------------|
| `ws_id` | string | Workstream ID |
**Response (success):** `200`
```json
{"deleted": "a1b2c3d4"}
```
**Response (not found):** `404`
```json
{"error": "Workstream not found"}
```
---
### `POST /v1/api/workstreams/{ws_id}/open`
Load a saved workstream into memory with its original `ws_id`. If the
workstream is already loaded, returns immediately with `already_loaded: true`.
**Path parameters:**
| Parameter | Type | Description |
|-----------|--------|----------------------|
| `ws_id` | string | Workstream ID |
**Response (success):** `200`
```json
{"ws_id": "a1b2c3d4", "name": "refactor"}
```
**Response (already loaded):** `200`
```json
{"ws_id": "a1b2c3d4", "name": "refactor", "already_loaded": true}
```
---
### `POST /v1/api/workstreams/{ws_id}/title`
Set a workstream title manually. The title is stored as the workstream alias.
**Path parameters:**
| Parameter | Type | Description |
|-----------|--------|----------------------|
| `ws_id` | string | Workstream ID |
**Request body:**
```json
{"title": "JWT Authentication Refactor"}
```
| Field | Type | Required | Description |
|---------|--------|----------|------------------------|
| `title` | string | yes | New workstream title |
**Response (success):** `200`
```json
{"status": "ok", "title": "JWT Authentication Refactor"}
```
**Response (conflict):** `409`
```json
{"error": "That name is already used by another workstream"}
```
---
### `POST /v1/api/workstreams/{ws_id}/refresh-title`
Regenerate the workstream title via LLM based on conversation content.
**Path parameters:**
| Parameter | Type | Description |
|-----------|--------|----------------------|
| `ws_id` | string | Workstream ID |
**Response (success):** `200`
```json
{"status": "ok"}
```
---
### `GET /v1/api/admin/settings`
List `interface.*` settings with their current values and sources. Requires
`read` scope on the server.
**Response:** `200`
```json
{
"settings": [
{
"key": "interface.close_tab_action",
"value": "last_used",
"source": "default",
"type": "str",
"description": "Determines which workstream to switch to after closing a tab."
}
]
}
```
---
### `POST|PUT /v1/api/admin/settings/{key}`
Update an `interface.*` setting. Only keys in the `interface` section are
accepted; other keys return `400`.
**Path parameters:**
| Parameter | Type | Description |
|-----------|--------|-------------------------------------|
| `key` | string | Setting key (e.g. `interface.theme`) |
**Request body:**
```json
{"value": "light"}
```
| Field | Type | Required | Description |
|---------|------|----------|----------------|
| `value` | any | yes | New value |
**Response (success):** `200`
```json
{"status": "ok", "key": "interface.theme", "value": "light"}
```
**Error:** `400` if the key is not in the `interface` section.
---
### `GET /v1/api/watches`
List active watches on this server node. Optionally filter by workstream.
@@ -1352,7 +1638,7 @@ version. Requires the `admin.skills` permission.
```json
{
"scan_status": "medium",
"risk_level": "medium",
"scan_report": "{\"composite\": 1.75, \"details\": {...}}",
"scan_version": "1"
}
@@ -1693,7 +1979,7 @@ Status code: `200` with an empty body.
| Malformed or unparseable JSON body | Treated as an empty dict `{}`; missing fields use defaults |
| Unknown `ws_id` | `404` with `{"error": "Unknown workstream"}` |
| Unknown path (GET or POST) | `404` with plain-text body `Not found` |
| Empty `message` on `/v1/api/send` | `400` with `{"error": "Empty message"}` |
| Empty `message` on `/v1/api/workstreams/{ws_id}/send` | `400` with `{"error": "Empty message"}` |
| Empty `command` on `/v1/api/command` | `400` with `{"error": "Empty command"}` |
| Rate limit exceeded | `429` with `Retry-After` header (see below) |
@@ -1736,7 +2022,7 @@ reconnection:
On reconnect, the server replays the full conversation history via the
`history` event, so the client can rebuild its UI state without data loss. The
same reconnection strategy applies to both the per-workstream SSE stream
(`/v1/api/events`) and the global state stream (`/v1/api/events/global`).
(`/v1/api/workstreams/{ws_id}/events`) and the global state stream (`/v1/api/events/global`).
---
@@ -1846,7 +2132,7 @@ turnstone_workstreams_active_total 1
# TYPE turnstone_http_requests_total counter
turnstone_http_requests_total{method="GET",endpoint="/health",status_code="200"} 42
turnstone_http_requests_total{method="GET",endpoint="/metrics",status_code="200"} 7
turnstone_http_requests_total{method="POST",endpoint="/v1/api/send",status_code="200"} 18
turnstone_http_requests_total{method="POST",endpoint="/v1/api/workstreams/{ws_id}/send",status_code="200"} 18
# HELP turnstone_tokens_total Total tokens consumed
# TYPE turnstone_tokens_total counter
turnstone_tokens_total{type="prompt"} 84320
@@ -1862,15 +2148,15 @@ turnstone_tool_calls_total{tool="read_file"} 3
## Console Routing Proxy Endpoints
These endpoints are served by the console (`turnstone-console`) and proxy
requests to the correct server node via the hash ring bucket cache. In
multi-node deployments, clients (SDK, channel gateway) talk to the console
instead of individual server nodes.
requests to the correct server node via rendezvous (HRW) hashing over the
live service registry. In multi-node deployments, clients (SDK, channel
gateway) talk to the console instead of individual server nodes.
### `POST /v1/api/route/workstreams/new`
Create a workstream via hash-ring routing. The console generates the `ws_id`,
routes to the assigned node, and includes `node_url` in the response for
direct SSE connections.
Create a workstream via rendezvous routing. The console generates the `ws_id`,
routes to the rendezvous-selected node, and includes `node_url` in the
response for direct SSE connections.
### `POST /v1/api/route/send`
@@ -1905,5 +2191,4 @@ Used by channel adapters to open direct SSE connections to the correct server no
Prometheus metrics for the console routing layer. Includes:
`turnstone_router_requests_total`, `turnstone_router_request_duration_seconds`,
`turnstone_ring_membership_size`, `turnstone_ring_version`,
`turnstone_ring_rebalance_total`, `turnstone_ring_migrations_total`.
`turnstone_router_membership_size`, `turnstone_router_refresh_total`.
+122 -50
View File
@@ -21,7 +21,8 @@ plugs in.
| `turnstone-console` | `turnstone.console.server` | ClusterCollector | Cluster dashboard (aggregates all nodes) |
| `turnstone-eval` | `turnstone.eval` | `NullUI` | Headless evaluation and prompt optimization |
| `turnstone-channel` | `turnstone.channels.cli` | ChannelAdapter | Channel gateway (Discord, Slack, etc.) |
| `turnstone-admin` | `turnstone.core.admin_cli` | — | Offline user and API token management |
| `turnstone-admin` | `turnstone.admin` | — | Offline user and API token management |
| `turnstone-bootstrap` | `turnstone.bootstrap` | — | LLM-guided setup wizard |
---
@@ -36,8 +37,12 @@ turnstone/
session.py ChatSession engine, SessionUI protocol, tool dispatch
providers/ LLM provider adapters (pluggable backend layer)
_protocol.py LLMProvider protocol, ModelCapabilities, StreamChunk, CompletionResult
_openai.py OpenAIProvider — OpenAI, vLLM, llama.cpp, any compatible API
_openai.py OpenAIProvider facade (re-exports Chat/Responses providers)
_openai_chat.py OpenAIChatCompletionsProvider — vLLM, llama.cpp, local compatible APIs
_openai_responses.py OpenAIResponsesProvider — commercial OpenAI Responses API
_openai_common.py Shared ModelCapabilities table + helpers
_anthropic.py AnthropicProvider — Anthropic Messages API, native streaming, thinking
_google.py GoogleProvider — Google Gemini via OpenAI-compat endpoint
__init__.py create_provider() + create_client() factory functions
workstream.py Parallel workstream manager (WorkstreamState, Workstream, WorkstreamManager)
tools.py Tool schema loader (JSON -> OpenAI function-calling format)
@@ -80,12 +85,13 @@ turnstone/
static/ Cluster dashboard web UI (page-specific HTML, CSS, JS)
channels/
cli.py Unified channel gateway entry point (turnstone-channel)
_protocol.py ChannelAdapter protocol, ChannelEvent dataclass
_protocol.py ChannelAdapter protocol
_routing.py ChannelRouter — channel/thread ↔ workstream mapping via HTTP
_config.py Base ChannelConfig dataclass
discord/ Discord adapter (bot, cog, views, streaming, config)
slack/ Slack adapter (Socket Mode bot, DM routing, approval buttons)
shared_static/ Shared design system (base.css, auth.js, theme.js, toast.js, utils.js, kb.js)
katex-0.16.44/ Vendored KaTeX math rendering library (MIT, woff2 fonts)
katex-0.16.45/ Vendored KaTeX math rendering library (MIT, woff2 fonts)
ui/
colors.py ANSI color constants with NO_COLOR support
markdown.py Streaming terminal markdown renderer (line-buffered)
@@ -96,7 +102,7 @@ turnstone/
renderer.js Markdown + LaTeX renderer (tables, nested lists, blockquotes, KaTeX math)
app.js Split-pane UI (Pane class, binary layout tree, SSE, tool approval)
tools/
*.json 15 tool schemas (OpenAI function-calling format + turnstone metadata)
*.json 19 tool schemas (OpenAI function-calling format + turnstone metadata)
```
Both UIs share a common design system extracted into `turnstone/shared_static/`: design tokens, login overlay, toast notifications, theme toggle, keyboard shortcuts, and utility functions. Each UI imports `base.css` and the shared JS modules at `/shared/`, then adds only page-specific code at `/static/`.
@@ -378,11 +384,11 @@ non-idle background workstreams above the input prompt.
(`Ctrl+\`, `Ctrl+Shift+\`). Max 6 panes; no duplicate workstreams across panes.
Layout persisted to `localStorage`.
- **Per-pane SSE**: `Pane.connectSSE(wsId)` opens
`/v1/api/events?ws_id=<id>` for each pane's event stream independently.
`/v1/api/workstreams/{ws_id}/events` for each pane's event stream independently.
- **Global SSE**: `connectGlobalSSE()` opens `/v1/api/events/global` which
receives `ws_state` broadcasts from all workstreams, used to update tab
indicators and pane headers without switching.
- **New tab / close**: POST `/v1/api/workstreams/new`, POST `/v1/api/workstreams/close`.
- **New tab / close**: POST `/v1/api/workstreams/new`, POST `/v1/api/workstreams/{ws_id}/close`.
### Thread Safety
@@ -442,13 +448,15 @@ from each schema and builds:
- `PRIMARY_KEY_MAP` -- `{name: primary_key}` for JSON fallback recovery
- `merge_mcp_tools(builtin, mcp_tools)` -- merges built-in + MCP tools at session init
### 13 Tools by Category
### 19 Tools by Category
**Read-only (auto-approve)**:
- `read_file` -- read file contents with optional offset/limit
- `diff_file` -- show diff between two files / versions
- `search` -- ripgrep-based codebase search
- `man` -- read man pages
- `recall` -- search conversation history
- `read_resource` -- read an MCP resource by URI
**Write (requires approval)**:
- `bash` -- execute shell commands (with safety checks via `turnstone.core.safety`)
@@ -457,13 +465,20 @@ from each schema and builds:
- `math` -- execute Python in sandboxed subprocess (via `turnstone.core.sandbox`)
- `web_fetch` -- fetch a URL (with SSRF protection via `turnstone.core.web`)
- `web_search` -- search the web (provider-native for Anthropic/OpenAI, Tavily fallback for local models)
- `notify` -- send a user-facing notification (Discord/Slack, optional reply routing)
- `watch` -- schedule a recurring poll with condition DSL
**Agent (delegated sub-sessions)**:
- `task` -- delegate to a sub-agent with full tool access (`TASK_AGENT_TOOLS`)
- `plan` -- explore codebase and write a structured plan (`AGENT_TOOLS`)
- `task_agent` -- delegate to a sub-agent with full tool access (`TASK_AGENT_TOOLS`)
- `plan_agent` -- explore codebase and write a structured plan (`AGENT_TOOLS`)
**Memory (structured persistent store)**:
**Memory / skills / prompts**:
- `memory` -- save, search, delete, or list memories (typed and scoped)
- `skill` -- invoke a skill (governed, versioned procedure)
- `use_prompt` -- fetch and apply a prompt template
Tool names are `plan_agent` / `task_agent` (not `plan` / `task`); bare words
collide with chat-template channels on some local models.
### Prepare / Execute Pattern
@@ -482,14 +497,14 @@ separation allows the UI to show previews before any side effects occur.
### Agent Tools
`task` and `plan` invoke `_run_agent()`, which runs a multi-turn loop with
a subset of tools and its own system prompt. The sub-agent runs
`task_agent` and `plan_agent` invoke `_run_agent()`, which runs a multi-turn
loop with a subset of tools and its own system prompt. The sub-agent runs
independently, then returns the final content as the tool result.
- **task**: uses `self._task_tools` (`TASK_AGENT_TOOLS` + MCP tools)
- **plan**: uses `self._agent_tools` (`AGENT_TOOLS` + MCP tools). Writes output
- **task_agent**: uses `self._task_tools` (`TASK_AGENT_TOOLS` + MCP tools)
- **plan_agent**: uses `self._agent_tools` (`AGENT_TOOLS` + MCP tools). Writes output
to `.plan-<ws_id>.md` — unique per `ChatSession` so concurrent workstreams
don't collide. On repeat invocations the prior `plan` tool call and its result
don't collide. On repeat invocations the prior `plan_agent` tool call and its result
are forwarded from `self.messages` so the agent refines the existing plan rather
than starting over. Planning instructions are injected as a developer message
prepended to the agent's conversation.
@@ -547,6 +562,21 @@ expanded tools).
**Tool naming:** `mcp__{server}__{tool}` — double underscore delimiter, validated
at connection time (server names with `__` are rejected).
**Resilience:** Each MCP server has an independent circuit breaker that opens
after 3 consecutive transport failures (timeouts, broken pipes, connection
resets). Cooldown uses capped exponential backoff (30 s base, 5 min max) with
per-server jitter to avoid thundering herd. Protocol-level errors (`McpError`)
from a healthy connection do not trip the breaker. When the cooldown expires
(half-open), the next operation attempt triggers automatic reconnection. Manual
`/mcp refresh` also clears the circuit on success. All sync bridge methods
(`call_tool_sync`, `read_resource_sync`, `get_prompt_sync`, `refresh_sync`)
cancel orphaned futures on timeout to prevent coroutine accumulation on the
background event loop. Push notification refreshes are debounced (5 s per
server) to protect against notification storms. The periodic refresh loop
attempts reconnection for disconnected servers with exponential backoff
(60 s1 h). Transport stream references are pre-closed before stack teardown to
work around the MCP SDK's anyio cancel-scope CPU busy-loop (SDK #2147).
**Error isolation:** Per-server connection/refresh failures are caught and logged; other
servers are unaffected. Tool execution errors return error strings to the LLM
rather than crashing the session.
@@ -578,6 +608,7 @@ LLMProvider (protocol)
|
+--- OpenAIProvider --- OpenAI, vLLM, llama.cpp, any /v1/chat/completions API
+--- AnthropicProvider --- Anthropic Messages API (native streaming, thinking)
+--- GoogleProvider --- Google Gemini via /v1beta/openai/ (extends OpenAIProvider)
```
**Protocol methods:**
@@ -631,6 +662,13 @@ both streaming and non-streaming responses. The `anthropic` SDK is imported
lazily so it remains an optional dependency (`pip install
turnstone[anthropic]`).
**GoogleProvider** (`_google.py`): extends `OpenAIChatCompletionsProvider` for
the Gemini `/v1beta/openai/` endpoint. Uses a single default
`ModelCapabilities` (2M context window, 65K max output tokens,
`token_param=max_tokens`) since Google updates models frequently. No static
per-model capability table. Google's endpoint is wire-compatible with the
OpenAI SDK, so no extra dependency is needed.
**Factory functions** (`__init__.py`): `create_provider(name)` returns a
singleton provider instance (thread-safe). `create_client(name, base_url,
api_key)` creates the appropriate SDK client.
@@ -659,6 +697,10 @@ api_key = "sk-..."
model = "gpt-5"
context_window = 400000
[models.gemini]
provider = "google"
model = "gemini-2.5-pro"
[model]
default = "local"
fallback = ["claude", "openai"]
@@ -666,7 +708,28 @@ agent_model = "claude"
```
Each `[models.*]` entry produces a `ModelConfig` with a `provider` field
(default: `"openai"`). Supported values: `"openai"` and `"anthropic"`.
(default: `"openai"`). Supported values: `"openai"`, `"anthropic"`, `"google"`,
and `"openai-compatible"`.
**Per-model sampling overrides:** Each model can specify `temperature`,
`max_tokens`, and `reasoning_effort` to override the global defaults from
ConfigStore. When unset (`NULL`), the global default is used.
```toml
[models.local]
base_url = "http://localhost:8000/v1"
model = "qwen3-32b"
temperature = 0.7
max_tokens = 8192
[models.o3]
base_url = "https://api.openai.com/v1"
api_key = "sk-..."
model = "o3"
reasoning_effort = "high"
# temperature omitted — uses global default
```
An optional `[models.*.capabilities]` sub-table overrides per-model
`ModelCapabilities` flags (useful for local models whose capabilities
cannot be detected programmatically):
@@ -680,9 +743,15 @@ model = "qwen-3.5-vl"
supports_vision = true
```
**Database model definitions:** On server entry points, models can also be
defined in the `model_definitions` table (admin Models tab). DB models support
the same per-model sampling overrides. Config.toml models override DB models
with the same alias in-memory (the DB rows are never modified).
**Lifecycle:**
1. `load_model_registry()` reads `[models.*]` sections from config.toml and
builds a `"default"` entry from CLI `--base-url`/`--model`/`--api-key` args
1. `load_model_registry()` loads DB model definitions (if storage available),
then overlays `[models.*]` from config.toml, then builds a `"default"` entry
from CLI `--base-url`/`--model`/`--api-key` args
2. The registry is passed to the session factory closure in both `cli.py` and
`server.py`; each workstream resolves its model on creation
3. `ModelRegistry.get_client()` lazily creates SDK client instances via
@@ -691,7 +760,8 @@ supports_vision = true
4. `ModelRegistry.get_provider()` lazily creates `LLMProvider` instances via
`create_provider()` (also cached and thread-safe)
5. `/model` command shows available models; `/model <alias>` switches the
active workstream's client, model, and context window
active workstream's client, model, context window, and per-model sampling
parameters
6. `_create_stream_with_retry()` tries the primary model, then each fallback
alias in order if the primary is unreachable
7. `_run_agent()` resolves `registry.agent_model` (if set) for plan/task
@@ -814,7 +884,7 @@ and are the single source of truth for both backends and Alembic migrations.
| `update_workstream_title(ws_id, title)` | Set/update LLM-generated title |
| `update_workstream_state(ws_id, state)` | Update workstream state and bump timestamp |
| `update_workstream_name(ws_id, name)` | Update workstream display name |
| `list_workstreams(node_id, limit)` | List workstreams, optionally by node |
| `list_workstreams(node_id, limit, *, parent_ws_id, kind, user_id)` | List workstreams, optionally filtered by node, parent, kind, or owning user |
| `kv_get(key)` / `kv_set(key, value)` / `kv_delete(key)` | Generic key-value store (backs memories table) |
| `kv_list()` / `kv_search(query)` | List or search key-value pairs |
| `search_history(query, limit)` | Full-text search (FTS5 on SQLite, tsvector on PostgreSQL) |
@@ -1030,8 +1100,8 @@ Three hierarchical scopes control endpoint access:
| Scope | Grants | Endpoints |
|-------|--------|-----------|
| `read` | SSE streams, workstream listing, history | GET endpoints |
| `write` | `read` + send, command, workstream create/close | POST to `/api/send`, `/api/command`, etc. |
| `approve` | `write` + tool approval, admin operations | POST to `/api/approve`, `/api/admin/*` |
| `write` | `read` + send, command, workstream create/close | POST to `/api/workstreams/{ws_id}/send`, `/api/command`, etc. |
| `approve` | `write` + tool approval, admin operations | POST to `/api/workstreams/{ws_id}/approve`, `/api/admin/*` |
### Middleware Flow
@@ -1055,8 +1125,9 @@ Three hierarchical scopes control endpoint access:
- **Console** is the auth management hub — it hosts the admin endpoints for
creating users, issuing API tokens, and managing channel mappings. User
records and token hashes live in the shared storage backend. The console
dashboard includes an **admin panel** (14 tabs) for managing
credentials, governance, MCP servers, and runtime settings through the browser.
dashboard includes an **admin panel** (18 tabs) for managing
credentials, governance, MCP servers, models, node metadata, and runtime
settings through the browser.
- **Server** is a JWT validator only — it validates tokens on each request but
never creates users or tokens. Both processes share the same `jwt_secret`
(via `TURNSTONE_JWT_SECRET` env var or `[auth].jwt_secret` config).
@@ -1127,12 +1198,12 @@ stderr so it does not interfere with readline. Tool execution may use a
Starlette ASGI app (served by uvicorn)
|
+-- Async request handlers (all under /v1/ prefix)
| POST /v1/api/send -> starts worker thread per workstream
| POST /v1/api/approve -> unblocks WebUI._approval_event
| POST /v1/api/plan -> unblocks WebUI._plan_event
| POST /v1/api/workstreams/new -> creates workstream + worker
| GET /v1/api/events -> SSE via EventSourceResponse (per workstream)
| GET /v1/api/events/global -> SSE via EventSourceResponse (fan-out)
| POST /v1/api/workstreams/{ws_id}/send -> starts worker thread per workstream
| POST /v1/api/workstreams/{ws_id}/approve -> unblocks WebUI._approval_event
| POST /v1/api/plan -> unblocks WebUI._plan_event
| POST /v1/api/workstreams/new -> creates workstream + worker
| GET /v1/api/workstreams/{ws_id}/events -> SSE via EventSourceResponse (per workstream)
| GET /v1/api/events/global -> SSE via EventSourceResponse (fan-out)
|
+-- ASGI middleware stack
| MetricsMiddleware -> CORSMiddleware -> AuthMiddleware -> RateLimitMiddleware
@@ -1206,10 +1277,10 @@ Monitoring (2 daemon threads) Control + Proxy (async Starlette)
| SSE manager | | GET /node/{node_id}/ |
| asyncio loop | | → httpx.AsyncClient |
| 1 task per node | | proxy to server_url |
| /events/global | | GET /node/{id}/v1/api/events |
| snapshot+deltas | | → SSE stream proxy |
+------------------+ | POST /node/{id}/v1/api/send |
| → forwarded to server |
| /events/global | | GET /node/{id}/v1/api/workstreams/{ws_id}/events |
| snapshot+deltas | | → SSE stream proxy |
+------------------+ | POST /node/{id}/v1/api/workstreams/{ws_id}/send |
| → forwarded to server |
+----------------------------+
```
@@ -1291,9 +1362,10 @@ setup, auth headers, `_request()` (REST) and `_stream_sse()` (SSE). Sync
clients delegate through `_SyncRunner` which maintains a persistent background
event loop on a daemon thread.
**Event types**: 27 standalone dataclasses in `events.py` with a type-registry
pattern matching `OutboundEvent.from_json()` from `mq/protocol.py`. Events are
decoupled from server internals.
**Event types**: 38 standalone dataclasses in `events.py` with a type-registry
dispatch (`from_json()` on each event). Events are decoupled from server
internals — the SDK parses SSE frames directly from the `/v1/api/events`
streams.
**TypeScript SDK**: `sdk/typescript/` — separate npm package with the same API
surface. Zero browser dependencies, SSE via `fetch` + `ReadableStream` parsing.
@@ -1315,7 +1387,8 @@ with TurnstoneServer("http://localhost:8080", token="tok_xxx") as client:
> See also: [Channel Integrations guide](channels.md)
The `turnstone-channel` gateway connects external messaging platforms
(Discord, Slack, Teams) to the turnstone cluster via HTTP. Each
(Discord and Slack today, with an adapter protocol for future platforms) to
the turnstone cluster via HTTP. Each
platform adapter implements the `ChannelAdapter` protocol and translates
between platform-native events and turnstone server API calls.
@@ -1328,7 +1401,7 @@ workstream is reactivated, the router uses atomic resume via the
the old workstream's conversation during creation in a single HTTP
request, eliminating ordering fragility.
Discord ships as the first adapter. See [channels.md](channels.md) for
Discord and Slack adapters ship today. See [channels.md](channels.md) for
setup instructions, configuration reference, and the adapter development
guide.
@@ -1349,11 +1422,11 @@ retries up to 3 times with backoff, re-querying the service registry on
each attempt. See [Notification Flow diagram](diagrams/png/17-notify-flow.png).
**Bidirectional replies:** When a user replies to a notification DM, the
Discord bot looks up the originating `ws_id` from the tracked message ID,
verifies the replying user matches the notification recipient, and routes
the reply to the workstream via `router.send_message()`. The workstream's
response is forwarded back to the DM via a temporary entry in
`_notify_reply_channels`. On `TurnCompleteEvent`, the response message is
channel adapter (Discord or Slack) looks up the originating `ws_id` from the
tracked message ID, verifies the replying user matches the notification
recipient, and routes the reply to the workstream via `router.send_message()`.
The workstream's response is forwarded back to the DM via a temporary entry
in `_notify_reply_channels`. On `TurnCompleteEvent`, the response message is
itself tracked for further replies, enabling multi-turn DM conversations
without requiring the user to open the web UI. Tracking entries are capped
at 100 (FIFO eviction) and cleaned up on workstream close.
@@ -1386,11 +1459,10 @@ and workstreams record which skill and version spawned them. Token budget
enforcement tracks consumption in `session.send()` with 80% warning and
100% approval gate via the `__budget_override__` synthetic tool name.
The console admin panel adds 5 governance tabs (Roles, Policies, Skills,
Usage, Audit), a Memories tab, a Settings tab (form-based editor for all
ConfigStore settings), and an MCP Servers tab (database-backed server
definitions with live connection status and cluster-wide reload) for a
total of 13 tabs, all permission-gated.
The console admin panel exposes these capabilities as 18 permission-gated
tabs: Users, API Tokens, Channels, Schedules, Watches, Roles, Policies,
Prompts, Judge, Skills, MCP Servers, Usage, Audit, Memories, Models, Nodes,
Settings, and TLS.
Both Python and TypeScript SDKs expose governance methods on the console
client.
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:75c1832b6079e8628f4bbf4ce98d37880c4de133636b7555e3869990b046ddc6
size 567704
oid sha256:5d500479d3be2363d4f594042a27e2ef5e2974750f580f6c4037a1fe85868ed9
size 251904
+237
View File
@@ -0,0 +1,237 @@
# Bulk endpoint shape contract
Turnstone exposes several endpoints and tool calls that take multiple
ids and return a per-id outcome. Over the last few phases two
**distinct** response shapes have settled, one per semantic category.
This doc codifies both so a future endpoint author can pick the right
shape by semantics instead of by coin-flip.
Existing bulk endpoints at time of writing:
| Endpoint / tool | Category | Response shape |
|---------------------------------------------------------|--------------------------|------------------------------------------|
| `GET /v1/api/cluster/ws/live?ids=a,b,c` | bulk read | `{results, denied, truncated}` |
| model tool `spawn_batch` | bulk create (per-item) | `{results, denied}` |
| `POST /v1/api/workstreams/{ws_id}/stop_cascade` | cascade mutation | `{cancelled, failed, skipped}` |
| `POST /v1/api/workstreams/{ws_id}/close_all_children` | cascade mutation | `{closed, failed, skipped}` |
---
## Why two shapes
The ask-to-outcome mapping is fundamentally different between the
two categories, and a one-size-fits-all envelope ends up papering
over distinctions the caller genuinely needs to branch on.
**Bulk read / bulk create-with-payload.** Each input id (or batch
index) carries a *request-side* concept — "give me the live block
for this ws_id" or "spawn a child with this spec" — and each
successful output carries a *payload* — the live block, or the new
workstream's identifying triple. The interesting distinction on
failure is *ownership / validation* (caller can't see that id, spec
was malformed) — independent of the storage state.
**Cascade mutation.** The action is uniform across every id (cancel
this subtree, close this child). The interesting distinctions on
outcome are *did it reach the terminal state?* (succeeded / already
was there / the dispatch itself failed) — driven by the storage
state plus transport reliability, not by the caller's input.
Trying to unify these forces either:
- a stateless `denied` bucket that has to carry "already gone"
*and* "you don't have permission" *and* "transport failed" with a
separate reason string — reviewers end up string-matching to branch.
- or a per-item-payload map for cascade mutations where every
successful value is the same sentinel — carrier with no payload.
So: two shapes, one per category. The rest of this doc spells out
each.
---
## Shape A — bulk read / bulk create-with-payload
```json
{
"results": { "<key>": <value-or-null>, ... },
"denied": [ "<key>", ... ],
"truncated": false
}
```
**`results`** is a key-indexed map of the positive-path payload.
The key is the input id for read endpoints (`cluster/ws/live` uses
the ws_id), or the input-array index (stringified) for create
endpoints that want ordering preserved (`spawn_batch` uses `"0"`,
`"1"`, ...). The value is whatever the endpoint produces per
success — a live block, a `{ws_id, name, node_id, status}` triple,
etc. A `null` value (read endpoints only) means "the id existed and
you own it, but the live block wasn't available" — distinct from
"denied".
**`denied`** is the negative-path list. For read endpoints it's a
flat list of ids (preserves input order so callers can re-zip
against their input). For create endpoints with per-item payloads
it's a list of `{idx, reason}` objects (`spawn_batch`'s validation
and spawn-error rows; also the operator-reject surface when per-item
selective-deny ships). Include every reason that's *not* the
positive path — authz, ownership, validation, already-consumed,
spawn failure — so callers don't branch on status codes.
**`truncated`** is a boolean set to `true` when the server's
per-endpoint input cap was exceeded and the tail was dropped. The
endpoint docs each spell out the cap (50 for `cluster/ws/live`).
`spawn_batch` hard-errors on overflow instead of silently
truncating — it omits the field entirely rather than carry a
permanently-false flag.
### Example — `cluster/ws/live`
```http
GET /v1/api/cluster/ws/live?ids=a1b2,c3d4,nonexistent,foreign HTTP/1.1
```
```json
{
"results": {
"a1b2": {"state": "running", "tokens": 12843, "activity": "..."},
"c3d4": null
},
"denied": ["nonexistent", "foreign"],
"truncated": false
}
```
Callers that need ordered output zip their original id list against
this map; ids in `denied` drop out of the zip cleanly. A live-block
`null` doesn't route to `denied` — the row exists and the caller
owns it; the node is just currently unreachable.
### Example — `spawn_batch`
```json
{
"results": {
"0": {"ws_id": "d4e5f6...", "name": "csrf-audit", "node_id": "gpu-3"},
"2": {"ws_id": "f1a2b3...", "name": "xss-audit", "node_id": "gpu-1"}
},
"denied": [
{"idx": 1, "reason": "skill not found: nonexistent-skill"}
]
}
```
Indexes are stringified to keep the envelope JSON-safe and
consistently-typed across the read and create cases.
---
## Shape B — cascade mutation
```json
{
"status": "ok",
"<bucket>": [ "<ws_id>", ... ],
"failed": [ "<ws_id>", ... ],
"skipped": [ "<ws_id>", ... ]
}
```
Where `<bucket>` is the endpoint-specific name for "succeeded" —
`cancelled` for `stop_cascade`, `closed` for `close_all_children`.
The three buckets partition the input set exactly once:
| Bucket | Meaning |
|---------------|-------------------------------------------------------------------------------|
| `<bucket>` | Action dispatch accepted; target reached the intended terminal state. |
| `failed` | Dispatch returned a non-404 error (transport issue, upstream 5xx, exception). |
| `skipped` | Upstream 404 — stale registry entry, row already deleted, or peer gone. |
The split between `failed` and `skipped` is load-bearing. `failed`
is actionable — the operator may want to retry, or the cascade may
be partial. `skipped` is pre-resolved — the target is already in
the terminal state the cascade was aiming at, so it's neither a
win to report nor a fault to fix.
### Example — `stop_cascade`
```json
{
"status": "ok",
"cancelled": ["child-1", "child-3"],
"failed": [],
"skipped": ["child-2"]
}
```
A subsequent retry would target only `failed` ids, not `skipped`
ones — the latter are already done.
### Example — `close_all_children`
```json
{
"status": "ok",
"closed": ["child-1", "child-3"],
"failed": ["child-2"],
"skipped": []
}
```
Same partition, different success-bucket name. When `coord_client`
is unavailable (session loaded but no HTTP client attached — a
construction bug) every id goes to `failed` so the operator notices
rather than getting a silent all-skipped response.
---
## Guidance for future bulk endpoints
1. **Pick by semantics, not by "what shape is nearby."**
- Mutation that's uniform across ids + terminal-state outcome? →
**Shape B** (cascade mutation).
- Read or create where the input id carries payload, or where the
denial axis is independent of storage state? → **Shape A**
(bulk read / bulk create-with-payload).
2. **Cap the input.** Both shapes assume a bounded input — the
server rejects or silently truncates past the cap. Document the
cap in the endpoint's OpenAPI description. Shape A uses
`truncated: true` on quiet truncation; Shape B hard-errors on
overflow.
3. **Match existing bucket names for the same semantic.** Use
`failed` and `skipped` verbatim in Shape B — the per-endpoint
success bucket is the only slot that varies. Use `results` and
`denied` verbatim in Shape A; the per-endpoint `<key>` /
`<value>` types vary.
4. **Audit the verbose shape.** Both endpoints emit a corresponding
audit event with the full before/after bucket lists — the SSE
stream and the in-process response give live feedback, but a
postmortem operator will read the audit row. Use
`_emit_coord_audit` (coordinator-scoped) or `record_audit`
directly; don't inline.
5. **Don't mix shapes within one endpoint.** If a bulk endpoint
wants both partial-success creation AND per-item failure reasons
(like `spawn_batch` with its `{idx, reason}` denial rows), that's
Shape A with a richer denial element — not a blend with Shape B.
---
## History
- **Phase 6** shipped `cluster/ws/live` as the first Shape A endpoint
(`{results, denied, truncated}`).
- **Phase 7** shipped `stop_cascade` as the first Shape B endpoint
(`{cancelled, failed, skipped}`).
- **Phase 8 PR A** shipped `spawn_batch` (Shape A, keyed by idx) and
`close_all_children` (Shape B, twin of `stop_cascade`), which
crystallised the two-shape-per-semantic-category policy codified
here.
Before adding a third shape, read this doc and argue for why the
new surface doesn't fit either A or B. Two idioms in the cluster
API is a finite operator tax; three is one too many.
+95 -21
View File
@@ -7,31 +7,35 @@ platform-native events (messages, button clicks, slash commands) into
turnstone API calls, and renders workstream output back into the
platform's UI.
Discord ships as the first adapter. The adapter protocol is designed for
future Slack and Teams integrations.
Discord and Slack adapters ship today. The adapter protocol is designed
so new platforms can be added with only a new package under
`turnstone/channels/<platform>/`.
---
## Architecture
```
Discord Gateway
|
v
turnstone-channel (Discord adapter)
|
v
turnstone-server (direct HTTP)
or
turnstone-console (routing proxy, multi-node)
Discord Gateway Slack (Socket Mode WebSocket)
\ /
v v
turnstone-channel (one or more adapters)
|
v
turnstone-server (direct HTTP)
or
turnstone-console (routing proxy, multi-node)
```
A single `turnstone-channel` process can run multiple adapters
simultaneously (e.g. Discord + Slack) — pass the tokens for each
platform you want to enable.
Key components:
- **ChannelAdapter protocol** (`turnstone/channels/_protocol.py`) — generic
interface for any messaging platform. Defines `start()`, `stop()`,
`send()`, `send_notification()`, `edit_message()`,
`send_approval_request()`, `send_plan_review()`, and `create_thread()`.
`send()`, and `send_notification()`.
- **ChannelRouter** (`turnstone/channels/_routing.py`) — maps
channel/thread IDs to turnstone workstream IDs. Handles workstream
creation via HTTP, stale route detection, and user identity resolution.
@@ -120,6 +124,68 @@ An admin can also force-link or unlink users via the console admin panel
---
## Slack Setup
Slack uses **Socket Mode**, so no public URL or API Gateway is required — Slack
connects outbound to the bot via a WebSocket. Install with:
```bash
pip install 'turnstone[slack]'
```
### 1. Create a Slack App
1. Go to https://api.slack.com/apps and click **Create New App**
2. Under **Settings > Socket Mode**, enable Socket Mode. This generates an
**App-Level Token** (prefix `xapp-`) — copy it.
3. Under **OAuth & Permissions**, add these **Bot Token Scopes**:
`chat:write`, `chat:write.public`, `channels:history`, `im:history`,
`groups:history`, `mpim:history`, `reactions:write`, `commands`
4. Under **Event Subscriptions** (Socket Mode delivers events), subscribe
to bot events: `message.channels`, `message.im`, `message.groups`
5. Under **Slash Commands**, create a command (default `/turnstone`)
6. Install the app to your workspace to generate the **Bot User OAuth
Token** (prefix `xoxb-`).
### 2. Configure Turnstone
**Environment variables** (recommended for Docker):
```bash
TURNSTONE_SLACK_TOKEN=xoxb-... # Bot User OAuth Token
TURNSTONE_SLACK_APP_TOKEN=xapp-... # App-Level Token (Socket Mode)
TURNSTONE_SLACK_CHANNELS= # optional, comma-separated channel IDs
TURNSTONE_SLACK_SLASH_COMMAND=/turnstone
```
**CLI flags** (bare-metal):
```bash
turnstone-channel \
--slack-token "xoxb-..." \
--slack-app-token "xapp-..." \
--slack-slash-command /turnstone \
--server-url http://localhost:8080
```
The Slack and Discord adapters can be enabled together — pass tokens for
both and the gateway hosts both adapters in one process.
### 3. Usage
- **DM the bot**: messages sent directly to the bot create a workstream
scoped to that DM; the slash command is not required.
- **Slash command**: `/turnstone <message>` in any channel the bot can
see starts a per-user channel session.
- Tool approvals render as Slack **Block Kit** buttons; only the user
who owns the workstream can approve/reject.
- Plan reviews render as a modal with approve / request-changes actions.
- Notifications and reply routing work identically to Discord.
- Session recovery: persisted channel routes are re-subscribed when the
bot restarts, so existing Slack conversations keep flowing.
---
## Usage
### Conversations
@@ -184,9 +250,13 @@ Plan review requests are displayed as a blue embed with:
| CLI Flag | Env Var | Default | Description |
|----------|---------|---------|-------------|
| `--discord-token` | `TURNSTONE_DISCORD_TOKEN` | — | Bot token (required to enable Discord) |
| `--discord-token` | `TURNSTONE_DISCORD_TOKEN` | — | Discord bot token (required to enable Discord) |
| `--discord-guild` | — | `0` (all guilds) | Restrict to a single Discord guild |
| `--discord-channels` | — | empty (all) | Comma-separated channel IDs to allow |
| `--discord-channels` | — | empty (all) | Comma-separated Discord channel IDs to allow |
| `--slack-token` | `TURNSTONE_SLACK_TOKEN` | — | Slack Bot User OAuth token (`xoxb-…`, required to enable Slack) |
| `--slack-app-token` | `TURNSTONE_SLACK_APP_TOKEN` | — | Slack App-Level token (`xapp-…`, required with `--slack-token`) |
| `--slack-channels` | `TURNSTONE_SLACK_CHANNELS` | empty (all) | Comma-separated Slack channel IDs to allow |
| `--slack-slash-command` | `TURNSTONE_SLACK_SLASH_COMMAND` | `/turnstone` | Slash command name registered in the Slack app |
| `--server-url` | `TURNSTONE_SERVER_URL` | `http://localhost:8080` | Server URL (single-node) |
| `--console-url` | `TURNSTONE_CONSOLE_URL` | — | Console URL (multi-node routing proxy) |
| `--model` | — | server default | Default model for new workstreams |
@@ -196,6 +266,9 @@ Plan review requests are displayed as a blue embed with:
| `--log-level` | `TURNSTONE_LOG_LEVEL` | `INFO` | Log level |
| `--log-format` | `TURNSTONE_LOG_FORMAT` | `auto` | Log format (`auto`/`json`/`text`) |
At least one of `--discord-token` or `--slack-token` must be supplied.
Passing both starts both adapters in the same process.
---
## User Identity
@@ -249,8 +322,8 @@ waiting for them to check in.
Two modes:
- **Username** — provide a turnstone `username`. The gateway resolves
it via the `channel_users` table and sends to all linked channels
(e.g. Discord + future Slack).
it via the `channel_users` table and sends to every linked platform
the user has (e.g. Discord + Slack).
- **Direct** — provide `channel_type` + `channel_id` to target a
specific platform channel or user DM.
@@ -351,10 +424,6 @@ class ChannelAdapter(Protocol):
async def stop(self) -> None: ...
async def send(self, channel_id: str, content: str) -> str: ...
async def send_notification(self, channel_id: str, content: str, ws_id: str) -> str: ...
async def edit_message(self, channel_id: str, message_id: str, content: str) -> None: ...
async def send_approval_request(self, channel_id: str, ws_id: str, correlation_id: str, items: list[dict]) -> None: ...
async def send_plan_review(self, channel_id: str, ws_id: str, correlation_id: str, content: str) -> None: ...
async def create_thread(self, parent_channel_id: str, name: str, message_id: str = "") -> str: ...
```
`send_notification()` is like `send()` but associates the outgoing
@@ -362,6 +431,11 @@ message with a `ws_id` so that user replies can be routed back to the
originating workstream. Adapters must track the mapping from outgoing
message ID to `(ws_id, target_user_id)` and handle DM replies.
Platform-specific concerns — approval prompts, plan reviews, message
edits, thread creation — live inside the adapter implementation and are
not part of the protocol surface. Each adapter drives those via its
own `_on_ws_event` dispatcher using SDK-native APIs.
To add a new platform:
1. Create `turnstone/channels/<platform>/` package
+18 -6
View File
@@ -334,7 +334,7 @@ The console reverse-proxies each node's server UI at `/node/{node_id}/`. This al
### URL Rewriting
The server UI uses root-relative URLs (`/v1/api/send`, `/static/app.js`, `/shared/base.css`, etc.). Since `<base>` tags cannot rewrite root-relative URLs, the console uses a JS shim approach:
The server UI uses root-relative URLs (`/v1/api/workstreams/{ws_id}/send`, `/static/app.js`, `/shared/base.css`, etc.). Since `<base>` tags cannot rewrite root-relative URLs, the console uses a JS shim approach:
1. **HTML rewriting** — when serving `index.html`, replaces `href=` and `src=` references to both `/static/` and `/shared/` with the proxy prefix (`/node/{node_id}/static/` and `/node/{node_id}/shared/` respectively).
@@ -344,7 +344,7 @@ The server UI uses root-relative URLs (`/v1/api/send`, `/static/app.js`, `/share
### SSE Proxy
SSE streams (`/v1/api/events`, `/v1/api/events/global`) are proxied as raw byte passthrough — the console opens an `httpx.AsyncClient.stream()` to the upstream server (with `read=None` and `pool=None` timeouts since SSE connections are long-lived) and relays every byte via `StreamingResponse`. This preserves server-side ping comments, event framing, and keepalives verbatim without parsing or re-encoding.
SSE streams (`/v1/api/workstreams/{ws_id}/events`, `/v1/api/events/global`) are proxied as raw byte passthrough — the console opens an `httpx.AsyncClient.stream()` to the upstream server (with `read=None` and `pool=None` timeouts since SSE connections are long-lived) and relays every byte via `StreamingResponse`. This preserves server-side ping comments, event framing, and keepalives verbatim without parsing or re-encoding.
### Authentication
@@ -382,6 +382,9 @@ Triggered by the "+ new" header button. A modal dialog with:
- **Profile** — optional dropdown listing enabled skills. Applies the skill's model, auto-approve policy, token budget, and other behavioral settings at creation time.
- **Name** — optional text input. Auto-generated if left empty.
- **Model** — optional text input for a model alias from the target node's registry.
- **Judge Model** — optional text input for the judge model alias (overrides the default judge model for this workstream).
Keyboard shortcuts: Ctrl+Shift+R (refresh title), Ctrl+Shift+E (edit title), Ctrl+Shift+F (fork), Ctrl+Shift+X (delete). Press ? for full shortcut help.
On submit, `POST /v1/api/cluster/workstreams/new` dispatches the creation request. A toast confirms success; the SSE stream delivers the `ws_created` event to update the dashboard.
@@ -393,10 +396,19 @@ The browser maintains a local `clusterState` object that mirrors the cluster sna
Accessed via the "admin" button in the header (visible when authenticated
with `approve` scope). Provides user, API token, channel link, MCP server,
and skill management with 13 tabs (see also
[Governance](governance.md) for
the Roles, Policies, Skills, Usage, and Audit tabs, and
[Settings](settings.md) for the database-backed configuration editor):
and skill management with 18 tabs (Users, API Tokens, Channels, Schedules,
Watches, Roles, Policies, Prompts, Judge, Skills, MCP Servers, Usage,
Audit, Memories, Models, Nodes, Settings, TLS). See also
[Governance](governance.md) for the Roles, Policies, Skills, Usage, and
Audit tabs, and [Settings](settings.md) for the database-backed
configuration editor.
The **Channels** tab links users to either a Discord or Slack account
via a per-row channel-type selector. The **Models** tab is a CRUD
editor for `model_definitions`, the **Nodes** tab edits per-node
metadata, and the **TLS** tab manages CA and leaf certificates for the
internal mTLS fabric. The **Settings** tab edits ConfigStore values
live; edits apply without restart.
**Users tab:**
+387
View File
@@ -0,0 +1,387 @@
# Coordinator API tour
Turnstone's **coordinator workstream** is a session hosted on the
console whose job is to orchestrate other workstreams. It runs an LLM
that can spawn child workstreams on any node, watch their progress,
wait for them to finish, steer them mid-flight, and tear them down.
This doc walks the full lifecycle — one request, one response, and the
relevant SSE events at each step.
Aimed at integrators driving a coordinator from a custom UI or SDK
without reverse-engineering the built-in console page. The shapes
here match the live OpenAPI spec served at `/openapi.json` and
rendered at `/docs` on every `turnstone-console` process. Every
step references the operation id from that spec so doc updates track
schema changes.
> **Auth throughout.** Every endpoint below sits behind bearer-token
> auth and the `admin.coordinator` permission. A session-scoped JWT
> is minted per login (see [docs/oidc.md](oidc.md) / [docs/security.md](security.md));
> a service token may call the read paths but destructive governance
> paths (`/restrict`, `/stop_cascade`, `/close_all_children`) require
> the explicit `admin.coordinator` grant — a service-token owner
> match isn't enough.
---
## The 9 steps
> **URL convergence (1.5.0).** Pre-1.5 coord-only endpoints lived
> under `/v1/api/coordinator/...`. The Stage 2 verb-shape lift
> consolidated coord and interactive onto the unified
> `/v1/api/workstreams/{ws_id}/<verb>` tree; coord still distinguishes
> itself via the `kind=coordinator` row classifier rather than a
> separate URL space. The endpoints below reflect the post-lift
> surface served by `turnstone-console`.
| # | Action | Operation |
|---|------------------------------|-------------------------------------------------------------|
| 1 | Create | `POST /v1/api/workstreams/new` |
| 2 | Subscribe to events | `GET /v1/api/workstreams/{ws_id}/events` (SSE) |
| 3 | Send a user message | `POST /v1/api/workstreams/{ws_id}/send` |
| 4 | Inspect children | `GET /v1/api/workstreams/{ws_id}/children` |
| 5 | Inspect one workstream | `GET /v1/api/cluster/ws/{ws_id}/detail` |
| 6 | Wait for fan-out | model-side tool `wait_for_workstream` |
| 7 | Govern | `POST /v1/api/workstreams/{ws_id}/trust` |
| | | `POST /v1/api/workstreams/{ws_id}/restrict` |
| | | `POST /v1/api/workstreams/{ws_id}/stop_cascade` |
| | | `POST /v1/api/workstreams/{ws_id}/close_all_children` |
| 8 | Approve / cancel | `POST /v1/api/workstreams/{ws_id}/approve` |
| | | `POST /v1/api/workstreams/{ws_id}/cancel` |
| 9 | Close | `POST /v1/api/workstreams/{ws_id}/close` |
Refer to `/openapi.json` (Swagger UI at `/docs`) on any
`turnstone-console` process for the authoritative operation ids and
schemas. Coordinator-only verbs (`/children`, `/trust`, `/restrict`,
`/stop_cascade`, `/close_all_children`) 404 against `kind=interactive`
rows; the shared verbs (`/send`, `/approve`, `/cancel`, `/events`,
`/history`, `/open`, `/close`, etc.) work on both kinds.
---
## 1. Create a coordinator
```http
POST /v1/api/workstreams/new
Content-Type: application/json
Authorization: Bearer <token>
{
"name": "release-coord",
"skill": "engineer-orchestrator",
"initial_message": "audit /auth for CSRF handling across all active routes"
}
```
```http
HTTP/1.1 201 Created
Content-Type: application/json
```
All three body fields are optional — an empty body still creates a
coordinator with an auto-generated name and no initial message.
Returns **503** with a remediation message when the cluster isn't
configured with a coordinator model; see
[`coordinator.model_alias`](settings.md) to set one.
**SSE implication:** the `ws_created` event fires on the cluster-wide
stream (`/v1/api/cluster/events`) once the row is committed. Per-ws
subscribers (step 2) see the session warm up as token traffic starts.
---
## 2. Subscribe to the per-coordinator event stream
```http
GET /v1/api/workstreams/{ws_id}/events HTTP/1.1
Accept: text/event-stream
Authorization: Bearer <token>
```
One persistent SSE connection per browser tab / SDK caller — the
console fans each event out to every listener queue (cap 500 events
per queue, put_nowait drop on overflow). Events come in flat JSON
with a `type` field. The recurring shapes a UI has to handle:
| `type` | Emitted when | Payload highlights |
|---------------------|--------------------------------------------------------------------------------------------|--------------------|
| `thinking_start` / `thinking_stop` | Model has entered / exited a reasoning block | — |
| `reasoning` | Reasoning-token stream chunk (when the model exposes it) | `text` |
| `content` | Assistant-content stream chunk | `text` |
| `stream_end` | End of a single provider stream | — |
| `tool_result` | A tool call completed (success or error) | `call_id`, `name`, `output`, `is_error?` |
| `tool_output_chunk` | Streaming tool output (e.g. long bash command) | `call_id`, `chunk` |
| `approve_request` | One or more tool calls need operator approval | `items: [{call_id, header, preview, func_name, approval_label, needs_approval}]` |
| `approval_resolved` | Operator answered the approval prompt | `approved`, `feedback` |
| `state_change` | Worker-thread state transition | `state``running`, `thinking`, `attention`, `idle`, `error` |
| `status` | Token usage + context-window snapshot (fires on every streaming tick) | `prompt_tokens`, `completion_tokens`, `total_tokens`, `context_window`, `pct`, `effort`, `cache_creation_tokens`, `cache_read_tokens` |
| `rename` | Session's display name changed | `name` |
| `intent_verdict` | Intent judge produced a verdict on a pending tool call | `risk_level`, `recommendation`, `reasons` |
| `output_warning` | Output guard flagged a tool result | `call_id`, `risk_level`, `flags` |
| `child_ws_created` | A direct child of this coord was just created (fan-out from the cluster bus) | `child_ws_id`, `node_id`, `name`, `parent_ws_id` (`ws_id` in the envelope is always the coord's own id) |
| `child_ws_state` | A direct child transitioned state | `child_ws_id`, `state` |
| `child_ws_closed` | A direct child closed | `child_ws_id` |
| `child_ws_rename` | A direct child's name changed | `child_ws_id`, `name` |
| `wait_started` / `wait_progress` / `wait_ended` | `wait_for_workstream` tool lifecycle (see §6) | `call_id`, `ws_ids`, `elapsed`, `results`, `complete` |
| `batch_started` / `batch_ended` | `spawn_batch` / `close_all_children` tool lifecycle | `call_id`, `op`, `total`/`succeeded`/`denied`/`closed`/`failed`/`skipped` |
| `info` / `error` | Operational messages | `message` |
**Reconnection contract:** a freshly-opened SSE connection receives
the current snapshot of any pending tool approval (`approve_request`
is re-sent if unresolved) and any in-flight `wait_*` / `batch_*`
indicator — so a tab refresh mid-approval doesn't strand the
operator.
---
## 3. Send the first user message
```http
POST /v1/api/workstreams/{ws_id}/send
Content-Type: application/json
{"message": "audit /auth for CSRF handling across all active routes"}
```
```http
HTTP/1.1 200 OK
{"status": "ok"}
```
The message is queued for the worker thread at its next tool-result
seam (so you can send follow-ups mid-conversation without corrupting
the in-progress turn). On the SSE stream you'll see `state_change`
`thinking_start` → streaming `reasoning` / `content` / `tool_result`
events, finishing with `state_change → idle` or an
`approve_request` when the model invokes a gated tool.
---
## 4. Inspect direct children
```http
GET /v1/api/workstreams/{ws_id}/children HTTP/1.1
```
```json
{
"items": [
{"ws_id": "d4e5f6...", "name": "csrf-audit", "state": "running", "node_id": "gpu-3"},
{"ws_id": "e1f2a3...", "name": "xss-audit", "state": "idle", "node_id": "gpu-1"}
],
"truncated": false
}
```
The response key is `items`, not `children` — the endpoint shape
follows the cluster-wide workstream-list idiom rather than the
coordinator `list_workstreams` tool's (which uses `children`).
Rows include every state stored for the parent (`running`, `idle`,
`closed`, ...); the endpoint does not accept a state query param,
so clients should inspect each row's `state` field and filter
locally if they want to hide closed/deleted children. Nested
coordinator rows are dropped server-side so only interactive
descendants appear.
---
## 5. Inspect one workstream (storage + live block + tail)
```http
GET /v1/api/cluster/ws/{ws_id}/detail?message_limit=20 HTTP/1.1
```
```json
{
"persisted": { "ws_id": "...", "state": "running", "parent_ws_id": "...", "kind": "interactive", ... },
"live": { "state": "thinking", "tokens": 12843, "activity": "...", "pending_approval": null },
"tail": [ {"role": "assistant", "content": "...", "tokens": 128}, ... ]
}
```
Works for any workstream the caller has `admin.cluster.inspect` on,
not just children of a single coordinator — useful for a cluster
admin panel watching multiple coordinators at once. `live` is
`null` when the owning node is unreachable or has dropped the row
from its dashboard cache; callers should degrade gracefully, not
treat it as an error.
For fan-out views, prefer
[`GET /v1/api/cluster/ws/live?ids=a,b,c`](bulk-endpoints.md) — it
collapses N per-row round-trips into one, returning the live block
for every id in a `{results, denied, truncated}` envelope.
---
## 6. Wait for fan-out (`wait_for_workstream`)
`wait_for_workstream` is a **model-side tool**, not an HTTP endpoint
— the coordinator's LLM invokes it with a list of child ws_ids, the
session's worker thread blocks inside the tool, and a sequence of
`wait_started` / `wait_progress` / `wait_ended` SSE events is emitted
for the UI to drive a "waiting on N children" indicator.
![wait_for_workstream sequence](diagrams/png/27-coordinator-wait-for-workstream.png)
Key properties:
- **Caps** — up to 32 ws_ids per call, up to 600 seconds per call.
A coordinator that needs to wait on more children re-invokes the
tool with a fresh timeout.
- **Modes** — `mode="any"` returns as soon as one child reaches a
real terminal state (`idle` / `error` / `closed` / `deleted`);
`mode="all"` waits for every polled child.
- **Progress throttling** — the poll loop runs every 500 ms but the
SSE emission is diff-on-state-change plus a 5-second heartbeat. A
600 s wait generates O(dozens) of progress events, not 1200.
- **Denied rows** — an id the caller doesn't own (cross-tenant) or a
missing row is reported as a `denied` state in the results dict;
`mode="any"` won't satisfy on a pure-denied list (the LLM should
treat it as a config error, not a completion).
Prefer `wait_for_workstream` over polling `inspect_workstream` in a
loop — a wait consumes one assistant turn regardless of how long the
children take, whereas each `inspect_workstream` poll costs a full
turn (plus judge, plus tokens). On a fan-out of 3+ children this
rounds to a 10× token-efficiency win.
---
## 7. Governance — trust, restrict, stop_cascade, close_all_children
These four endpoints let an operator steer a live coordinator session
mid-flight. All four emit an audit event tagged
`coordinator.<action>` via the dedicated audit executor so a cascade
burst can't starve audit writes.
### `POST /trust` — auto-approve own-subtree sends
```http
POST /v1/api/workstreams/{ws_id}/trust
{"send": true}
```
Flips `trust_send=true` on the live session. Subsequent
`send_to_workstream` calls that target a ws_id in the coordinator's
own subtree skip the approval prompt; foreign ws_ids and other tool
calls still go through the normal flow. Requires both
`admin.coordinator` AND `coordinator.trust.send` permissions (the
second grants a service token the opt-in it otherwise wouldn't get).
### `POST /restrict` — revoke tool access mid-session
```http
POST /v1/api/workstreams/{ws_id}/restrict
{"revoke": ["spawn_workstream", "delete_workstream"]}
```
Unions the names into the session's revoked-tools set. Additive and
idempotent — calling twice with overlapping lists converges to the
union. Revocations don't survive a session close/reopen; operators
opt in per session. Cap 256 tool names per request, 128 chars each.
### `POST /stop_cascade` — cancel the subtree
```http
POST /v1/api/workstreams/{ws_id}/stop_cascade
{}
```
Cancels the coordinator's in-flight generation AND dispatches
`cancel_workstream` through the routing proxy for every direct
child in the in-memory registry. Returns:
```json
{"status": "ok", "cancelled": ["child-1", "child-3"], "failed": [], "skipped": ["child-2"]}
```
Response uses the [cascade-mutation bulk shape](bulk-endpoints.md):
`cancelled` = accepted, `failed` = dispatch error worth retrying,
`skipped` = upstream 404 (already gone — stale registry entry or
the row was deleted between snapshot and dispatch). Grandchildren
aren't touched directly; they sit behind their parent's cancel and
propagate via the child's SSE stream.
### `POST /close_all_children` — soft-close the direct fan-out
```http
POST /v1/api/workstreams/{ws_id}/close_all_children
{"reason": "audit round complete"}
```
Response:
```json
{"status": "ok", "closed": ["c-1", "c-2"], "failed": [], "skipped": []}
```
Soft-close cascade bounded by the same semaphore as `stop_cascade`.
The `reason` (up to 512 chars) propagates into each closed child's
audit + `workstream_config` for postmortem. Unlike `stop_cascade`
this does NOT recurse into grandchildren — the model-facing tool
that pairs with this endpoint asks for a bounded teardown of the
coordinator's own fan-out. For a full-subtree teardown, use
`stop_cascade`.
See [bulk-endpoints.md](bulk-endpoints.md) for why both endpoints
share the cascade-mutation shape and how it differs from the
`spawn_batch` / `cluster/ws/live` shape.
---
## 8. Approve / cancel
The `approve` endpoint is what resolves an `approve_request` SSE
event. The coordinator's worker thread is blocked inside
`ui.approve_tools` waiting for this POST.
```http
POST /v1/api/workstreams/{ws_id}/approve
{"approved": true, "feedback": null, "always": false}
{"approved": false, "feedback": "spawn count looks too high try 3 not 10"}
{"approved": true, "feedback": null, "always": true} // always-approve this tool name
```
`cancel` drops the in-flight generation but leaves the coordinator
idle and open for a fresh `send`:
```http
POST /v1/api/workstreams/{ws_id}/cancel
{}
```
---
## 9. Close
```http
POST /v1/api/workstreams/{ws_id}/close
{}
```
Soft-closes the session — state persists, children keep running (use
`close_all_children` or `stop_cascade` first to wind them down), the
worker thread exits, SSE streams send a final `stream_end` and
disconnect. The row is reopenable via
`POST /v1/api/workstreams/{ws_id}/open` so long as it hasn't been
deleted.
---
## Further reading
- [coordinator-skills.md](coordinator-skills.md) — writing a skill
that runs on a coordinator session (orchestrator persona,
workflow patterns, `SkillKind` classifier).
- [bulk-endpoints.md](bulk-endpoints.md) — the two bulk-shape
idioms (`{results, denied, truncated}` vs
`{<bucket>, failed, skipped}`) used by `cluster/ws/live`,
`spawn_batch`, `stop_cascade`, and `close_all_children`.
- [architecture.md](architecture.md) — cluster-wide architecture
including how coordinator sessions fit next to node-hosted
interactive workstreams.
- The live OpenAPI spec (`/openapi.json` on any console process)
and Swagger UI (`/docs`) — authoritative schemas for every
endpoint above.
endpoint above.
+323
View File
@@ -0,0 +1,323 @@
# Writing a coordinator-specific skill
Skills are prompt-level personas that steer a Turnstone session
toward a narrow task. Most skills target **interactive** sessions —
the single-workstream "do this thing" surface where the model wields
`bash`, `edit_file`, `web_fetch`, and the rest of the maker toolset.
A **coordinator skill** is different. It runs on a session whose job
is to orchestrate other sessions. The toolset is smaller and
narrower, the persona is an orchestrator instead of a maker, and the
success metric is "did the plan resolve" instead of "did the code
compile". This doc covers the differences a skill author has to
care about.
---
## The two-surface model
A row in `prompt_templates` carries a `kind` column (see
[`turnstone/core/skill_kind.py`](../turnstone/core/skill_kind.py);
migration 044 added the column). Three values:
| `SkillKind` enum | Stored as | Visible in |
|----------------------|-----------------------------|---------------------------------------------------------------------------|
| `SkillKind.INTERACTIVE` | `"interactive"` | Only the interactive-session activation path. `list_skills` on a coord won't show it. |
| `SkillKind.COORDINATOR` | `"coordinator"` | Only the coordinator's `list_skills` tool. Hidden from interactive activation pickers. |
| `SkillKind.ANY` | `"any"` | Both surfaces. Default for legacy rows predating the classifier. |
The `kind` field is a `StrEnum` — drop-in ``str`` compatible — so
DB rows, JSON payloads, and `==` comparisons all work without
translation at the edge.
When a coordinator calls `list_skills`, the SQL filter narrows to
`kind IN ('coordinator', 'any')`. When an interactive session picks
a skill at activation, the filter narrows to
`kind IN ('interactive', 'any')`. A skill author tags once at
creation; the two surfaces stay partitioned without any
per-call filtering on the LLM side.
**Tagging a new skill as coordinator-only** — set `kind` to
`SkillKind.COORDINATOR` (or the literal string `"coordinator"`) when
you POST to `/v1/api/admin/skills`. Existing rows default to
`SkillKind.ANY`; bump them to `COORDINATOR` if you've rewritten the
prompt around the orchestrator toolset.
---
## Tool surface differences
Coordinator sessions receive a **fixed** tool set, defined in
`turnstone/core/tools.py` as `COORDINATOR_TOOLS`. Nothing a skill
or MCP config can do adds to it. Current members:
| Tool | Category | Notes |
|---------------------------|-----------------|---------------------------------------------------------------------|
| `spawn_workstream` | delegate | Create one child. Requires approval. |
| `spawn_batch` | delegate | Create up to 10 children in one approval. Partial-success shape. |
| `inspect_workstream` | observe | Read state + tail of one child. Auto-approved (no mutation). |
| `list_workstreams` | observe | List the direct children (same shape as `/children` endpoint). |
| `wait_for_workstream` | block | Block until one/all listed children hit a terminal state. |
| `send_to_workstream` | steer | Queue a follow-up message to a running child. |
| `close_workstream` | wind-down | Soft-close one child. Requires approval. |
| `close_all_children` | wind-down | Soft-close every direct child in one approval. Partial-success shape. |
| `cancel_workstream` | wind-down | Drop the in-flight generation; leaves child idle for a fresh send. |
| `delete_workstream` | wind-down | Hard-delete one child. Requires approval. |
| `list_nodes` | discover | Enumerate live cluster nodes + capabilities. |
| `list_skills` | discover | Coordinator-visible skills only (SkillKind filter above). |
| `tasks` | plan | Orchestrator-only scratchpad. Children don't see it. |
Explicitly **not** in the coordinator set:
- `bash` / `edit_file` / `write_file` / `append_file` / `diff_file` — no local FS.
- `read_file` / `search` — no local FS reads.
- `web_fetch` / `web_search` — no direct web access.
- `task_agent` / `plan_agent` — sub-agent tools are zeroed on coord sessions.
- `memory` / `recall` / `notify` / `watch` / `read_resource` / `use_prompt` / `skill` — the orchestrator's "memory" is its children's outputs; these UX / persistence tools belong to interactive sessions.
If your skill needs a coordinator to "run a command" or "read a
file", write the delegate pattern instead: spawn a child with an
appropriate skill, `wait_for_workstream`, then `inspect_workstream`
for the output. The coordinator stays the orchestrator.
---
## Persona differences
Interactive skills compose on top of `base_interactive.md` — a
"maker" persona: get the work done, use the tools, edit the code,
close the loop.
Coordinator skills compose on top of
[`base_coordinator.md`](../turnstone/prompts/base_coordinator.md) —
an "orchestrator" persona: decompose, delegate, monitor, synthesise.
The base text is short but sets the tone every coordinator skill
inherits:
> You are a coordinator on a small, focused infrastructure team.
> Your role is to orchestrate work across the cluster... You do
> not edit files, run shell commands, browse the web, or manipulate
> the codebase directly. Children do that.
Write your skill's system prompt to *add* task-specific orchestration
hints on top — don't re-explain the role, don't paste tool JSON,
don't try to override the "no direct action" contract. Keep the
additions to: (a) the specific kind of work this skill delegates;
(b) the preferred skill tags for children; (c) the synthesis shape
the skill should end on.
---
## `tasks` integration
`tasks` is the coordinator's scratchpad — a persisted, ordered
list of rows with fields `{id, title, status, child_ws_id, created,
updated}` that only this coordinator sees. Children don't see it;
the user does via the sidebar. Five actions: `add`, `update`,
`remove`, `reorder`, `list` (only `list` is auto-approved; the
mutators go through the approval flow).
The input schema refers to rows by `task_id`; the persisted row
object exposes the same id as `id`. The `child_ws_id` field is a
free-form label the skill sets to link a task to a spawned
workstream — it is NOT validated against the workstreams table, so
a skill can set it to a placeholder before `spawn_workstream`
returns or keep it pointing at a closed child for later audit.
A skill's initial prompt can seed the task list by calling
`tasks(action="add", title=...)` as its very first tool calls —
the user gets a visible plan before any child is spawned, and the
coordinator's future self has something concrete to iterate on.
Status transitions (`pending` → `in_progress` → `done` / `blocked`)
are the skill's main feedback loop: mutate the task when the child
covering it finishes, not when the child starts. Use
`tasks(action="update", task_id=..., child_ws_id=<ws_id>)` to
link a task to the child that owns it once spawn returns.
A final gotcha: parallel tool dispatch does NOT serialise reads
after writes in the same batch. If a skill issues an `update` and
a `list` in one parallel tool batch, the `list` response may reflect
the pre-update state. Dispatch mutate and list serially (one
tool_use turn each) when the list must observe the mutation.
Keep the tasks coarse-grained — one per child, roughly. A 20-task
list for a 3-child fan-out is noise; a 1-task list for a 5-child
fan-out loses the plan. The sidebar renders tasks as the operator's
mental model of "what the coord thinks it's doing".
---
## Referencing children by `ws_id`
Every ws_id returned by `spawn_workstream` / `spawn_batch` is a
**full 32-char hex string**. The skill's system prompt must not
invent ws_ids — a model that hallucinates `"child-1"` or `"ws-abc"`
hits the tenant guard in `CoordinatorClient._is_own_subtree`, which
validates ws_id against `parent_ws_id=coord_ws_id` AND
`user_id=owner` in storage. The rejection shape varies by tool:
- **Mutating ops** (`send_to_workstream`, `close_workstream`,
`cancel_workstream`, `delete_workstream`) return
`{"error": "workstream not in coordinator subtree: <ws_id>", "status": 404}`
— the skill should treat this as a tool error, not an empty result.
- **`inspect_workstream`** returns `{"error": "workstream not found", "ws_id": "<ws_id>"}`
(same shape as a genuinely missing row, so the guard can't be
used as an existence oracle).
- **`wait_for_workstream`** reports the offending id with
`state="denied"` in its `results` dict; `mode="any"` won't
satisfy on a pure-denied list, so a hallucinated id won't trick
the wait into reporting "complete".
Pattern: capture each spawn result in the next tool call's input.
The JSON tool-result carries `{"ws_id": "...", "name": "...",
"node_id": "...", "routing_strategy": "..."}`; the model should
extract the ws_id and pass it to `inspect_workstream` /
`wait_for_workstream` / `send_to_workstream` / `close_workstream`
verbatim.
A UI that wants human-readable identifiers should render the `name`
field and keep the ws_id as the click-through key.
---
## `wait_for_workstream` vs `inspect_workstream`
Two distinct semantics, different cost profiles:
- **`wait_for_workstream(ws_ids=[...], timeout=60, mode="any")`** —
blocks inside a single tool call until one (or all, for `mode="all"`)
of the listed children reaches a terminal state (`idle`, `error`,
`closed`, `deleted`). The worker thread blocks up to `timeout`
seconds; the assistant turn remains a single round-trip regardless
of how long the wait actually takes. Prefer this for "the plan
needs child X to finish before the next step."
- **`inspect_workstream(ws_id=...)`** — single read of the child's
state + tail. Costs a full assistant turn (judge, tokens, stream).
Prefer this for "what does the final message say?" after the child
has already resolved (via `wait_for_workstream` or a known
transition).
Rule of thumb: wait once for a fan-out, then inspect once per
child for the content. A loop of inspect-every-few-seconds is a
token-burning antipattern — on 3+ children it rounds to a 10×
efficiency hit over a wait+inspect pair.
---
## Common coordinator patterns
Three patterns cover most coordinator skills. Pick the one that
matches the task, or combine them deliberately.
### Pattern 1 — delegate-and-summarise
One specialist child, one focused brief, one synthesis message back
to the user. Appropriate when the user's request is "run the thing
and tell me what happened" and the work fits in one workstream.
```
tasks(action='add', title='audit /auth for CSRF')
spawn_workstream(skill='engineer', initial_message='audit /auth ...')
wait_for_workstream(ws_ids=[<child>], timeout=300)
inspect_workstream(ws_id=<child>)
→ synthesise the final message into a user-facing response
tasks(action='update', task_id='t_01', status='done')
close_workstream(ws_id=<child>, reason='audit complete')
```
### Pattern 2 — fan-out-and-synthesise
N children running in parallel, each with a distinct brief, all
waited-on together, then synthesised. Appropriate when the user's
request naturally decomposes into independent subtasks.
```
tasks seeds:
t_01 benchmark Anthropic 4.7 latency on summarisation
t_02 benchmark OpenAI GPT-5.2 latency on summarisation
t_03 benchmark Gemini 2.5 latency on summarisation
spawn_batch(children=[...3 briefs...])
wait_for_workstream(ws_ids=[c1, c2, c3], mode='all', timeout=600)
inspect_workstream(ws_id=c1); ...(c2); ...(c3)
→ synthesise head-to-head comparison
tasks → all done
close_all_children(reason='benchmark complete')
```
Prefer `spawn_batch` over 3 individual `spawn_workstream` calls —
one approval instead of three, one audit trail, deterministic
sibling ordering. Pair with `wait_for_workstream(mode='all')` and
`close_all_children(reason=...)` to wind the fan-out down in one
approval each.
### Pattern 3 — plan-then-delegate
The coordinator first uses its own reasoning to carve the plan,
records it in `tasks`, then spawns children that each own one
task. Appropriate when the user's request is "figure out how to X"
and the coordinator's planning step is itself valuable.
```
→ coord reasons about the shape of the work
tasks(action='add', title='...') × N # the plan, visible in the sidebar
for task in tasks:
spawn_workstream(skill=..., initial_message=task.brief)
tasks(action='update', task_id=task.id, notes='ws=<child_ws_id>')
wait_for_workstream(ws_ids=[...], mode='all', timeout=...)
for child in children:
inspect_workstream(ws_id=child)
tasks(action='update', task_id=..., status='done', notes='result summary')
→ synthesise
```
The key distinction from Pattern 2: the plan is an artifact the user
can see and interact with (via the sidebar). If the coordinator's
reasoning-pass was wrong about the decomposition, the user can
course-correct before any child runs.
---
## Testing a coordinator skill
Coordinator sessions are hosted on the console, not on a node.
Integration tests that drive a real coord session live under
`tests/test_coordinator_end_to_end.py` — they spin a console with
an in-memory SQLite backend and a fake upstream node, then drive
the session through its HTTP surface.
For a new coordinator skill:
1. Write the skill prompt as a string and pass it to the
`coord_session` fixture's `skill=` kwarg (see
`tests/test_coordinator_tools.py` for the pattern).
2. Build a small fake cluster: one node + two children via
the `_seed_children` helper in `tests/_coord_test_helpers.py`
(``_seed_children(mgr._adapter, coord.id, ["child-1", "child-2"])``).
3. Drive the session with seeded tool_call dicts matching the
provider layer's shape. The unit-level tests in
`tests/test_coordinator_tools.py` show the helper (`_tc(name,
args, call_id)`).
4. Assert the skill's decision shape — which tools fire in what
order, what the tasks looks like at the end, which
`_error` reasons appear on the denied-path.
A full end-to-end test isn't required for every skill; a
prepare-step unit test that asserts "given this initial message, the
first tool call is X with Y args" is usually sufficient to catch
persona drift without a real LLM in the loop.
---
## Further reading
- [coordinator-api-tour.md](coordinator-api-tour.md) — the HTTP
surface every coordinator skill indirectly drives.
- [bulk-endpoints.md](bulk-endpoints.md) — the response shape
`spawn_batch` and `close_all_children` use, so your skill can
parse results / denied arrays correctly.
- [governance.md](governance.md) — the broader governance surface
(`/trust`, `/restrict`, `/stop_cascade`, role-based permissions)
that wraps every coord session.
- [settings.md](settings.md) — `coordinator.model_alias` and
`coordinator.reasoning_effort` settings that gate which LLM runs
the coordinator session at all.
+27 -33
View File
@@ -1,34 +1,27 @@
# Consistent Hash Ring — Reference Design
**Status**: Reference (not currently in the hot path)
**Date**: 2026-03-30
**Status**: Reference — alternative routing strategy
## Overview
Live routing uses **rendezvous (HRW) hashing** in
`turnstone/core/rendezvous.py` and `turnstone/console/router.py`. This
document captures a vnode-ring approach as a reference for future
evaluation if the cluster outgrows rendezvous's O(N)-per-route
characteristic.
This document describes a consistent hash ring algorithm evaluated during
the design of the direct HTTP transport routing system. The current
implementation uses weight-proportional bucket assignment with a
donor/recipient rebalancing algorithm (see `direct-http-transport.md`).
The consistent hash ring is documented here as a reference for future
scalability work — if the cluster grows beyond the point where the
weight-proportional approach is sufficient, the ring provides a
proven alternative with stronger stability guarantees.
The FNV-1a-32 hash function specified below is bit-identical to the
hash used by the live rendezvous implementation; cross-language clients
can rely on these test vectors.
## When to consider the ring approach
## When the ring approach becomes interesting
The current weight-proportional seeding + donor/recipient rebalancer works
well when:
- Cluster size is moderate (< 50 nodes)
- Nodes join/leave infrequently
- The rebalancer runs centrally (in the console)
The vnode ring becomes preferable to rendezvous hashing when:
The consistent hash ring becomes advantageous when:
- Cluster size grows large (50+ nodes) and frequent membership changes
cause the donor/recipient algorithm to churn
- Decentralized routing is needed (each node computes the ring locally,
no central console required)
- Cross-language determinism is important (multiple implementations must
agree on the same assignment without sharing state)
- Cluster size grows large (50+ nodes) and the per-route O(N) hash
computation becomes visible against downstream HTTP cost.
- Decentralised routing is needed (each node computes the ring locally,
no central console required).
- A precomputed flat-array lookup is desired so the routing hot path
avoids hashing entirely.
## Algorithm
@@ -133,16 +126,17 @@ class HashRing:
# Precompute all 65536 bucket assignments
```
## Comparison with current approach
## Comparison with rendezvous (HRW) hashing
| Aspect | Weight-proportional (current) | Consistent hash ring |
|--------|------------------------------|---------------------|
| Seeding | Exact weight split, deterministic | Hash-based, ~3% variance |
| Node addition | Donor/recipient moves only excess | Ring moves ~1/N buckets |
| Node removal | Dead buckets → most underloaded | Ring redistributes to clockwise neighbors |
| Cross-node churn | Zero (only donor→recipient) | Zero (ring stability guarantee) |
| Decentralized | No (needs central rebalancer) | Yes (each node computes locally) |
| Complexity | Simple weight arithmetic | Virtual node construction + bisect |
| Aspect | Rendezvous (live) | Consistent hash ring (this doc) |
|--------|-------------------|---------------------------------|
| Per-route cost | O(N) hash computes | O(log V) bisect against precomputed array |
| Seeding | None — pure function | Build vnode array on every membership change |
| Node addition | Pure function moves ~1/N keys | Ring moves ~1/N buckets |
| Node removal | Surviving nodes' keys unchanged | Surviving nodes' buckets unchanged |
| Decentralised | Yes — pure function over services | Yes each node computes locally |
| Persistent state | None | None on the hot path; precomputed array in memory |
| Complexity | ~20 LOC | Virtual-node construction + bisect |
## Test vectors
+1 -1
View File
@@ -43,7 +43,7 @@ eval --> sqlite : SQLite
console --> server : HTTP proxy\n(hash-ring bucket lookup,\nproxy /node/{id}/* traffic)
channel --> server : HTTP + SSE\n(POST /v1/api/send,\nGET /v1/api/events)
channel --> server : HTTP + SSE\n(POST /v1/api/workstreams/{ws_id}/send,\nGET /v1/api/workstreams/{ws_id}/events)
' Notes
note right of console
+9 -4
View File
@@ -19,13 +19,14 @@ package "Entry Points" <<Rectangle>> {
component [cli.py\nturnstone] as cli <<entry>>
component [server.py\nturnstone-server] as server <<entry>>
component [eval.py\nturnstone-eval] as eval <<entry>>
component [chat.py\n(re-exports)] as chat <<entry>>
component [admin.py\nturnstone-admin] as admin <<entry>>
component [bootstrap.py\nturnstone-bootstrap] as bootstrap <<entry>>
}
' Core engine
package "turnstone/core/" <<Rectangle>> {
component [session.py\nChatSession, SessionUI] as session <<core>>
component [providers/\nLLMProvider, OpenAI, Anthropic] as providers <<core>>
component [providers/\nLLMProvider, OpenAI, Anthropic, Google] as providers <<core>>
component [workstream.py\nWorkstreamManager] as workstream <<core>>
component [tools.py\nTool loader] as tools <<core>>
component [memory.py\nPersistence facade] as memory <<core>>
@@ -48,7 +49,8 @@ package "turnstone/core/" <<Rectangle>> {
package "turnstone/channels/" <<Rectangle>> {
component [_routing.py\nChannelRouter] as router <<channel>>
component [discord/bot.py\nDiscordBot] as discordbot <<channel>>
component [gateway.py\nturnstone-channel] as gateway <<channel>>
component [slack/bot.py\nSlackBot (Socket Mode)] as slackbot <<channel>>
component [cli.py\nturnstone-channel] as gateway <<channel>>
}
' Console
@@ -112,7 +114,8 @@ eval --> memory
eval --> config
eval --> tools
chat --> session
admin --> auth
bootstrap --> providers
' Core internal deps
session --> providers
@@ -135,8 +138,10 @@ tools --> schemas
' Channel dependencies
gateway --> discordbot
gateway --> slackbot
gateway --> router
discordbot --> sdkserver : HTTP + SSE
slackbot --> sdkserver : HTTP + SSE
router --> storage : channel_routes
' Console dependencies
+17 -1
View File
@@ -103,6 +103,18 @@ class "AnthropicProvider" as AnthropicProv {
core/providers/_anthropic.py
}
class "GoogleProvider" as GoogleProv {
+ provider_name: str
+ get_capabilities(model) -> ModelCapabilities
--
Extends OpenAIChatCompletionsProvider
for Gemini /v1beta/openai/ endpoint.
Single default ModelCapabilities
(2M context, 65K output).
--
core/providers/_google.py
}
' ModelCapabilities
class "ModelCapabilities" as ModelCaps <<frozen>> {
+ context_window: int
@@ -283,7 +295,7 @@ class "ModelRegistry" as ModelReg {
--
Thread-safe lazy client + provider
creation. Loaded by load_model_registry()
from CLI args + [models.*] config.
from DB + [models.*] config + CLI args.
--
core/model_registry.py
}
@@ -294,6 +306,9 @@ class "ModelConfig" as ModelCfg <<frozen>> {
+ base_url: str
+ model: str
+ context_window: int
+ temperature: float | None
+ max_tokens: int | None
+ reasoning_effort: str | None
}
' Circuit breaker state
@@ -360,6 +375,7 @@ SessionUI <|.. NullUI
LLMProvider <|.. OpenAIProv
LLMProvider <|.. AnthropicProv
OpenAIProv <|-- GoogleProv
ChatSession --> SessionUI : uses
ChatSession --> LLMProvider : delegates LLM calls
+6 -6
View File
@@ -170,15 +170,15 @@ Server --> Browser : Shimmed app.js
deactivate Server
note right of Browser
All fetch("/v1/api/send") calls in the
server UI now become fetch("/node/nodeA/v1/api/send"),
All fetch("/v1/api/workstreams/{ws_id}/send") calls in the
server UI now become fetch("/node/nodeA/v1/api/workstreams/{ws_id}/send"),
routed through the console proxy.
end note
Browser -> Server : GET /node/nodeA/v1/api/events?ws_id=ws789
Browser -> Server : GET /node/nodeA/v1/api/workstreams/ws789/events
activate Server #FFF9C4
Server -> NodeA : GET http://10.0.1.1:8080/v1/api/events?ws_id=ws789\n(SSE stream via httpx.AsyncClient timeout=None)
Server -> NodeA : GET http://10.0.1.1:8080/v1/api/workstreams/ws789/events\n(SSE stream via httpx.AsyncClient timeout=None)
activate NodeA
loop SSE streaming
@@ -189,10 +189,10 @@ end
deactivate NodeA
deactivate Server
Browser -> Server : POST /node/nodeA/v1/api/send\n{message:"hello", ws_id:"ws789"}
Browser -> Server : POST /node/nodeA/v1/api/workstreams/ws789/send\n{message:"hello"}
activate Server #FFF9C4
Server -> NodeA : POST http://10.0.1.1:8080/v1/api/send\n(body forwarded)
Server -> NodeA : POST http://10.0.1.1:8080/v1/api/workstreams/ws789/send\n(body forwarded)
activate NodeA
NodeA --> Server : {status:"ok"}
deactivate NodeA
+1 -1
View File
@@ -23,7 +23,7 @@ interface "StorageBackend" as SB <<protocol>> {
+resolve_workstream(alias_or_id) → str | None
+delete_workstream(ws_id) → bool
+prune_workstreams(retention_days) → (int, int)
+list_workstreams(node_id, limit) → list
+list_workstreams(node_id, limit, *, parent_ws_id, kind, user_id) → list
+save_workstream_config(ws_id, config)
+load_workstream_config(ws_id) → dict
+kv_get(key) → str | None
+1 -1
View File
@@ -79,7 +79,7 @@ class "Scope Hierarchy" as SH <<scope>> {
--
GET → read
POST write paths → write
POST /api/approve → approve
POST /api/workstreams/{ws_id}/approve → approve
/api/admin/* → approve
}
+34 -13
View File
@@ -20,11 +20,14 @@ class "Discord" as Discord <<platform>> {
asyncio event loop
}
class "Slack (future)" as Slack <<platform>> {
Socket Mode / Events API
class "Slack" as Slack <<platform>> {
Socket Mode WebSocket
Block Kit messages
Slash command (default /turnstone)
DM + channel events
--
Planned integration
slack-bolt (Python)
asyncio event loop
}
class "Teams (future)" as Teams <<platform>> {
@@ -38,7 +41,7 @@ class "Teams (future)" as Teams <<platform>> {
class "turnstone-channel" as ChannelService <<service>> {
entry point: turnstone-channel
--
One process per platform
One process — hosts one or more adapters
asyncio event loop
Structured logging (structlog)
--log-level, --log-format
@@ -47,6 +50,19 @@ class "turnstone-channel" as ChannelService <<service>> {
GET /health
}
class "SlackBot" as SlackBot <<service>> {
+on_message(event)
+on_action(action) (Block Kit buttons)
+send(channel_id, content)
+send_notification(channel_id, content, ws_id)
+run(bot_token, app_token)
--
slack-bolt AsyncApp
Socket Mode client
Per-user channel sessions via slash command
DM routing without slash command
}
class "DiscordBot" as Bot <<service>> {
+on_message(msg)
+on_interaction(interaction)
@@ -79,10 +95,10 @@ class "ChannelRouter" as Router <<service>> {
' -- Server --
class "turnstone-server" as Server <<server>> {
POST /v1/api/send
POST /v1/api/approve
POST /v1/api/workstreams/{ws_id}/send
POST /v1/api/workstreams/{ws_id}/approve
POST /v1/api/workstreams/new
GET /v1/api/events?ws_id=
GET /v1/api/workstreams/{ws_id}/events
--
LLM execution + tool use
SSE event stream
@@ -132,16 +148,21 @@ Bot --> Router : on_message\non_interaction
Router --> CU : resolve identity
Router --> CR : resolve / register route
Router --> Server : POST /v1/api/send\nPOST /v1/api/approve\nPOST /v1/api/workstreams/new
Bot --> Server : GET /v1/api/events?ws_id=\n(SSE via httpx-sse)
Router --> Server : POST /v1/api/workstreams/{ws_id}/send\nPOST /v1/api/workstreams/{ws_id}/approve\nPOST /v1/api/workstreams/new
Bot --> Server : GET /v1/api/workstreams/{ws_id}/events\n(SSE via httpx-sse)
Server --> Bot : SSE event stream
Bot --> Discord : reply / embed\nbutton callback
Slack .[hidden]. Discord
Slack --> SlackBot : socket-mode\nevents
SlackBot --> Router : on_message / on_action
SlackBot --> Server : POST /v1/api/workstreams/{ws_id}/send\nGET /v1/api/workstreams/{ws_id}/events
SlackBot --> Slack : post / update\nBlock Kit button callbacks
Teams .[hidden]. Slack
ChannelService --> Bot : creates + runs
ChannelService --> SlackBot : creates + runs
ChannelService --> Router : creates
ChannelService --> SVC : register / heartbeat /\nderegister
@@ -158,7 +179,7 @@ note right of Bot
(or creates new workstream)
4. ChannelRouter resolves platform user -> user_id
via channel_users table
5. Router sends POST /v1/api/send to server
5. Router sends POST /v1/api/workstreams/{ws_id}/send to server
**Workstream Resume (evicted workstreams)**
1. Stale route detected (no active SSE listener)
@@ -172,7 +193,7 @@ end note
note right of Server
**Outbound Flow**
1. Server emits SSE events on
GET /v1/api/events?ws_id=
GET /v1/api/workstreams/{ws_id}/events
2. Bot subscribes via httpx-sse
3. Bot formats and sends to Discord thread
end note
@@ -183,7 +204,7 @@ note bottom of CR
2. Bot renders Discord buttons (Approve / Deny)
3. User clicks button -> on_interaction()
4. Router builds ApproveMessage
5. Router sends POST /v1/api/approve to server
5. Router sends POST /v1/api/workstreams/{ws_id}/approve to server
end note
note bottom of CU
+28 -1
View File
@@ -152,10 +152,34 @@ MCPMgr -> MCPSrv : prompts/get
MCPSrv --> MCPMgr : GetPromptResult
MCPMgr --> Session : messages [{role, content}]
== Resilience: Circuit Breaker & Stream Safety ==
note over MCPMgr
**Per-server circuit breaker**
CLOSED --(3 failures)--> OPEN
OPEN --(cooldown expires)--> half-open probe
Probe success --> CLOSED (trip_count decays by 1)
Probe failure --> OPEN (cooldown doubles, max 5 min)
McpError (protocol) does NOT trip breaker.
BrokenPipeError / EOFError evicts dead session.
All sync methods cancel orphaned futures on timeout.
Transport streams pre-closed before stack teardown
to avoid anyio cancel-scope CPU busy-loop (SDK #2147).
end note
Session -> MCPMgr : call_tool_sync()
MCPMgr -> MCPMgr : _cb_gate(server)\n[reject if circuit open]
MCPMgr -> MCPMgr : _cb_auto_reconnect()\n[if session gone + cooldown expired]
MCPMgr -> MCPSrv : tools/call
MCPSrv --> MCPMgr : result or error
MCPMgr -> MCPMgr : _cb_record_success()\nor _cb_record_failure()
== Three-Tier Refresh ==
group Push Notifications
group Push Notifications (debounced 5s per server)
MCPSrv -> MCPMgr : ToolListChangedNotification
MCPMgr -> MCPMgr : debounce check\n(skip if < 5s since last)
MCPMgr -> MCPMgr : _refresh_server_tools()
MCPSrv -> MCPMgr : ResourceListChangedNotification
@@ -172,6 +196,9 @@ group Periodic Polling (default 4h)
Only polls capabilities
without push support.
Staggered per-server.
Disconnected servers get
reconnect attempts with
exponential backoff (60s-1h).
end note
end
+1 -1
View File
@@ -211,7 +211,7 @@ note over Session, Judge
**Storage:**
intent_verdicts table (migration 012), output_assessments table
(migration 022). Both queryable via admin API endpoints
(requires admin.judge permission). Skills store scan_status,
(requires admin.judge permission). Skills store risk_level,
scan_report, scan_version for install-time risk assessment.
end note
@@ -0,0 +1,89 @@
@startuml
title Turnstone - coordinator wait_for_workstream lifecycle
skinparam sequenceArrowThickness 1.5
skinparam noteBackgroundColor #FDF6E3
participant "Coordinator\nLLM" as LLM
participant "ChatSession\n(worker thread)" as CS
participant "CoordinatorClient" as CC
participant "SessionUI\n(SSE fanout)" as UI
participant "Console routing\nproxy" as RP
database "Storage\n(workstreams row)" as DB
participant "Child\nnode" as NODE
== Spawn ==
LLM -> CS : tool_call spawn_workstream(...)
activate CS
CS -> CC : spawn(initial_message=...,\nparent_ws_id=coord, user_id=...)
CC -> RP : POST /v1/api/route/workstreams/new
RP -> NODE : dispatch (rendezvous)
NODE -> DB : insert workstreams row\nstate='running'
RP --> CC : {ws_id, node_id, name, status: 200}
CC --> CS : {ws_id, ...}
CS -> UI : on_tool_result\n("spawn_workstream", ws_id)
deactivate CS
note right of LLM
Model now knows the child ws_id.
It can inspect / send / wait, and
the parent registry tracks it.
end note
== Wait (blocking) ==
LLM -> CS : tool_call wait_for_workstream\n(ws_ids=[child], mode="any", timeout=60)
activate CS
CS -> CS : _prepare_wait_for_workstream\n(validate ws_ids, timeout, mode)
CS -> UI : emit wait_started\n{call_id, ws_ids, mode, timeout}
CS -> CC : wait_for_workstream(ws_ids, timeout,\nmode, progress_callback)
activate CC
loop every 500ms up to timeout
CC -> DB : read workstreams row(s)
DB --> CC : {state, updated, tokens, ...}
alt state in {idle, error, closed, deleted}
note over CC
real-terminal state ->
completion condition met
end note
else still running / thinking / attention
CC -> CS : progress_callback(snap)\n(diff-on-change or 5s heartbeat)
CS -> UI : emit wait_progress\n{call_id, elapsed, results?}
end
end
CC --> CS : {complete, elapsed,\nresults: {ws_id: snap}}
deactivate CC
CS -> UI : emit wait_ended\n{call_id, complete, elapsed, results}
CS -> UI : on_tool_result\n("wait_for_workstream",\n"complete after Ns (R/N resolved)")
CS --> LLM : tool_result (full results dict)
deactivate CS
note left of UI
Sidebar "waiting on N children" indicator
keys on call_id - started / progress / ended
scope to a single wait invocation so
nested waits render independent badges.
end note
== After wait: inspect + close ==
LLM -> CS : tool_call inspect_workstream(ws_id=child)
CS -> CC : inspect(ws_id)
CC -> DB : read row + tail
CC --> CS : {state, messages, tokens, ...}
CS --> LLM : tool_result (serialised)
LLM -> CS : tool_call close_workstream\n(ws_id=child, reason="...")
CS -> CC : close_workstream(ws_id, reason)
CC -> RP : POST /v1/api/route/workstreams/close
RP -> NODE : dispatch
NODE -> DB : state='closed',\nclose_reason='...'
RP --> CC : {status: 200}
CC --> CS : {closed: true, status: 200, reason: ...}
CS --> LLM : tool_result
@enduml
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:75da80e6bd205e45b9fe48aa2f87197110c908f44e1d98c52eee69f956274711
size 400402
oid sha256:a3b5c59403a6febd81667fc8fd2a7d22bc59da6130eba0dea5449c42668d0ede
size 387044
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6471e611beebf647f3a191eb16588571a404cc52a43067883a2b6f06dd936376
size 594676
oid sha256:474b900448ec04d1117b48a2b55614524721b2f04ac4bda66170bd0a06aae0f2
size 624573
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8a5957b71822656098cbe779ca619b7e56e9b11b0f1c4eac4fc880ed62b71a2a
size 358670
oid sha256:ae4f79fb22600106f8cb0af4ba5586bb26ea5d57e27ef382fdc59b6549fdbd21
size 415473
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a6b7769aa7e732ffbeb1eb7f5b65273a135fb3a78d9802ec36d3b92801c34f6b
size 427745
oid sha256:7623df33be9baf7647ca1c2450640df57e1cd73e8be1f8168aae16e546ad683c
size 459941
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:aa12d81dc578f7e65bf4df3152b3de1736289c422f83d0b0cd32107726722357
size 172028
+18 -4
View File
@@ -22,7 +22,7 @@ Console dashboard: http://localhost:8090
|---------|------|---------|-------------|
| `server` | 8080 | default | Web UI + chat workstreams + LLM |
| `console` | 8090 | default | Cluster dashboard |
| `channel` | — | production | Channel gateway (Discord, Slack, etc.) |
| `channel` | — | production | Channel gateway (Discord and/or Slack adapters) |
| `server-1``server-10` | — | cluster | 10-node server fleet (PostgreSQL required) |
## Profiles
@@ -83,11 +83,15 @@ Auth is always enabled. `TURNSTONE_JWT_SECRET` is required.
| Variable | Default | Description |
|----------|---------|-------------|
| `TURNSTONE_DB_BACKEND` | `sqlite` | Storage backend: `sqlite` or `postgresql` |
| `TURNSTONE_DB_URL` | — | Database URL (e.g. `postgresql://user:pass@db:5432/turnstone`). For SQLite, defaults to `/data/.turnstone.db` |
| `TURNSTONE_DB_URL` | — | Database URL (e.g. `postgresql+psycopg://user:pass@postgres:5432/turnstone`). For SQLite, defaults to `/data/.turnstone.db` |
| `TURNSTONE_DB_POOL_SIZE` | `2` | PostgreSQL connection pool size per process (default: 2 base + 3 overflow = 5 max) |
| `POSTGRES_USER` | `turnstone` | PostgreSQL container username (used in default `TURNSTONE_DB_URL` for cluster/channel) |
| `POSTGRES_PASSWORD` | — | PostgreSQL container password (required for production and cluster profiles) |
The database stores workstream history, user accounts, and API tokens. When using JWT auth, a database backend is required for user storage.
> **Upgrading from <1.3.0a4:** Earlier versions used `DB_BACKEND` and `DATABASE_URL` in `.env`, which `compose.yaml` mapped to the `TURNSTONE_`-prefixed names internally. These short aliases have been removed. Rename `DB_BACKEND` → `TURNSTONE_DB_BACKEND` and `DATABASE_URL` → `TURNSTONE_DB_URL` in your `.env` file.
> **Large clusters:** Each turnstone process maintains a small connection pool (5 max). At hundreds of nodes this adds up — use [PgBouncer](pgbouncer.md) in transaction pooling mode between turnstone and PostgreSQL.
> **First-time setup:** After deploying with auth enabled, create an initial admin user by running `turnstone-admin create-user` inside the container:
@@ -104,8 +108,16 @@ The database stores workstream history, user accounts, and API tokens. When usin
|----------|---------|-------------|
| `TURNSTONE_DISCORD_TOKEN` | — | Discord bot token (required to enable Discord adapter) |
| `TURNSTONE_DISCORD_GUILD` | `0` | Restrict to a single Discord guild (0 = all guilds) |
| `TURNSTONE_SLACK_TOKEN` | — | Slack Bot User OAuth token `xoxb-…` (required to enable Slack adapter) |
| `TURNSTONE_SLACK_APP_TOKEN` | — | Slack App-Level token `xapp-…` (required with `TURNSTONE_SLACK_TOKEN`) |
| `TURNSTONE_SLACK_CHANNELS` | — | Comma-separated Slack channel IDs to allow (empty = all) |
| `TURNSTONE_SLACK_SLASH_COMMAND` | `/turnstone` | Slash command registered in the Slack app |
The channel service runs in the `production` profile. When `TURNSTONE_DISCORD_TOKEN` is set, the Discord adapter connects to the Discord Gateway and routes messages to the server via HTTP. See [Channel Integrations](channels.md) for full setup instructions including Discord application creation and user account linking.
The channel service runs in the `production` profile. When
`TURNSTONE_DISCORD_TOKEN` or the Slack pair is set the gateway starts the
corresponding adapter; both can run in one process. See
[Channel Integrations](channels.md) for platform app setup and user
account linking.
## Scaling
@@ -137,7 +149,9 @@ docker compose build
docker compose build --no-cache
```
All entry points are installed in a single image: `turnstone-server`, `turnstone-console`, `turnstone-channel`, `turnstone-admin`, `turnstone-eval`.
All entry points are installed in a single image: `turnstone`,
`turnstone-server`, `turnstone-console`, `turnstone-channel`,
`turnstone-admin`, `turnstone-eval`, and `turnstone-bootstrap`.
## Cleanup
+12 -6
View File
@@ -62,14 +62,14 @@ etc.) since workstream templates were merged into the skills system in v0.8.0.
- **Default skills**: All `is_default=true` skills auto-apply to new
workstreams, concatenated in alphabetical order by name. Use name prefixes
(e.g. `01-safety`, `02-style`) to control ordering.
- **Explicit selection**: `--template <name>` CLI flag, `template` field on
- **Explicit selection**: `--skill <name>` CLI flag, `skill` field on
`POST /v1/api/workstreams/new`, console creation modal dropdown, scheduled task
config, and channel adapter config. An explicit skill *replaces* defaults.
- **Variables**: Three built-in placeholders resolved at load time:
`{{model}}` (active model name), `{{ws_id}}` (workstream ID),
`{{node_id}}` (server node ID). Unrecognized placeholders are kept as-is.
- **Runtime switching**: `/template <name>` to switch, `/template clear` to revert
to defaults, `/template` to show current. Persisted across resume.
- **Runtime switching**: `/skill <name>` to switch, `/skill clear` to revert
to defaults, `/skill` to show current. Persisted across resume.
- **Model-driven loading**: The `skill` built-in tool lets the model
discover and activate skills mid-conversation. `search` action finds skills
by query (auto-approved); `load` action activates by name (requires user
@@ -91,7 +91,7 @@ etc.) since workstream templates were merged into the skills system in v0.8.0.
time. The scanner evaluates four risk axes: content risk (command execution,
data exfiltration), supply chain risk (pipe-to-shell, transitive installs),
vulnerability risk (prompt injection, insecure credentials), and declared
capability risk (from `allowed-tools` in SKILL.md). Results populate the `scan_status`
capability risk (from `allowed-tools` in SKILL.md). Results populate the `risk_level`
(safe/low/medium/high/critical) and `scan_report` (JSON breakdown) columns.
These fields are system-managed and cannot be overwritten via the admin API.
- **Discovery**: External skills can be discovered and installed from registries:
@@ -186,15 +186,21 @@ Full OpenAPI spec at `/openapi.json` and Swagger UI at `/docs`.
## Admin Console UI
6 new tabs added to the admin panel (11 total):
Governance-related tabs within the 18-tab admin panel:
- **Roles** — CRUD roles, permission checkbox grid, user role assignment modal
- **Policies** — CRUD tool policies with colored action badges (green/red/amber)
- **Skills** — CRUD skills with wide modal, textarea editor
- **Prompts** — Prompt-policy editor (heuristics for admin guardrails)
- **Skills** — CRUD skills with wide modal, textarea editor; Discover pill for
installing from skills.sh / GitHub; per-row scan badges (safe/low/med/high/critical)
- **Judge** — Intent validation configuration and verdict history
- **Usage** — Summary readouts + CSS bar chart, time range + group-by selectors
- **Audit** — Filterable log with relative timestamps, load-more pagination
Tabs are permission-gated: hidden if the user lacks the required permission.
See [docs/console.md](console.md) for the full tab list and
[docs/settings.md](settings.md) for the Settings tab that edits live
ConfigStore values.
## SDK
+16 -4
View File
@@ -41,6 +41,7 @@ confidence_threshold = 0.7 # reserved for v2 smart approvals (not used in v1)
max_context_ratio = 0.5 # max % of judge context window for history
timeout = 60.0 # seconds (generous for local models)
read_only_tools = true # judge can use read_file/list_directory
cancel_on_approval = false # stop judging remaining tool calls once user decides
```
All fields are optional. The judge is enabled by default; use `enabled = false`
@@ -72,6 +73,17 @@ CLI flags override `config.toml` values.
- **Cross-provider**: When both `model` and `provider` are set, the judge
creates its own LLM client. You can optionally specify `base_url` and
`api_key` for non-default endpoints.
- **Google models**: The judge supports `google` as a provider. Note that
read-only tools are disabled for Google models (the Gemini API requires
`thought_signature` in tool call round-trips which the judge's normalized
format does not preserve).
The judge creates a fresh HTTP client for each evaluation run and closes it
when done, avoiding stale connection issues across runs.
If the LLM judge fails or returns no verdict, a fallback verdict with tier
`llm_fallback` is delivered via the callback, ensuring the UI always receives
a result.
---
@@ -303,7 +315,7 @@ four independent risk axes:
`Bash(*)` (unrestricted shell) is high risk. `Bash(git:*)` is low.
Read-only tools are safe.
Results are stored in `scan_status` (tier: safe/low/medium/high/critical) and
Results are stored in `risk_level` (tier: safe/low/medium/high/critical) and
`scan_report` (JSON breakdown) on the `prompt_templates` table. These fields are
system-managed and not editable via the admin API.
@@ -388,11 +400,11 @@ level, annotations, output length, redaction status).
### Session-level skill scan warning
When a skill with `scan_status` of `high` or `critical` is loaded into a
When a skill with `risk_level` of `high` or `critical` is loaded into a
session, a warning is emitted via `on_info`:
```
⚠ Skill 'my-skill' has scan status: high.
⚠ Skill 'my-skill' has risk level: high.
Review scan report in admin panel before enabling in production.
```
@@ -409,7 +421,7 @@ All three evaluation systems persist their assessments for future calibration:
|-------|--------|-------------|
| `intent_verdicts` | Intent judge (heuristic + LLM) | `func_name`, `risk_level`, `confidence`, `user_decision` |
| `output_assessments` | Output guard | `func_name`, `risk_level`, `flags`, `redacted` |
| `prompt_templates` | Skill scanner | `scan_status`, `scan_report`, `scan_version` |
| `prompt_templates` | Skill scanner | `risk_level`, `scan_report`, `scan_version` |
Run v1 with all tools requiring manual approval to build a local dataset.
In v2, calibration tooling will analyze this data to:
+1 -1
View File
@@ -146,7 +146,7 @@ with TurnstoneConsole("http://localhost:8081", token="...") as client:
### TypeScript
```typescript
import { TurnstoneConsole } from "@anthropic/turnstone-sdk";
import { TurnstoneConsole } from "@turnstone/sdk";
const client = new TurnstoneConsole({
baseUrl: "http://localhost:8081",
+16 -14
View File
@@ -40,18 +40,20 @@ Add PgBouncer between turnstone services and PostgreSQL:
```yaml
services:
pgbouncer:
image: bitnami/pgbouncer:latest
image: edoburu/pgbouncer:latest
environment:
POSTGRESQL_HOST: postgres
POSTGRESQL_PORT: "5432"
POSTGRESQL_DATABASE: turnstone
POSTGRESQL_USERNAME: ${POSTGRES_USER:-turnstone}
POSTGRESQL_PASSWORD: ${POSTGRES_PASSWORD:?}
PGBOUNCER_POOL_MODE: transaction
PGBOUNCER_DEFAULT_POOL_SIZE: "40"
PGBOUNCER_MAX_CLIENT_CONN: "5000"
PGBOUNCER_MAX_DB_CONNECTIONS: "80"
PGBOUNCER_SERVER_IDLE_TIMEOUT: "300"
DB_HOST: postgres
DB_PORT: "5432"
DB_NAME: ${POSTGRES_DB:-turnstone}
DB_USER: ${POSTGRES_USER:-turnstone}
DB_PASSWORD: ${POSTGRES_PASSWORD:?}
LISTEN_PORT: "6432"
AUTH_TYPE: ${POSTGRES_AUTH_TYPE:-scram-sha-256}
POOL_MODE: transaction
DEFAULT_POOL_SIZE: "40"
MAX_CLIENT_CONN: "5000"
MAX_DB_CONNECTIONS: "80"
SERVER_IDLE_TIMEOUT: "300"
ports:
- "6432:6432"
networks:
@@ -67,7 +69,7 @@ services:
```
Then point turnstone services at PgBouncer instead of PostgreSQL
directly by changing the `DATABASE_URL` (or `TURNSTONE_DB_URL`):
directly by changing `TURNSTONE_DB_URL`:
```bash
# Before (direct)
@@ -82,7 +84,7 @@ TURNSTONE_DB_URL=postgresql://turnstone:secret@pgbouncer:6432/turnstone
## Helm / Kubernetes
Add a PgBouncer deployment or use a Helm chart like
[bitnami/pgbouncer](https://github.com/bitnami/charts/tree/main/bitnami/pgbouncer).
[edoburu/pgbouncer](https://github.com/edoburu/docker-pgbouncer/tree/master/examples/kubernetes).
In `values.yaml`, point the database at PgBouncer:
@@ -106,7 +108,7 @@ pgbouncer:
maxClientConn: 5000
maxDbConnections: 80
```
:
---
## Configuration reference
+24 -15
View File
@@ -1,17 +1,24 @@
# Release Process
Turnstone uses two parallel release tracks published from a single PyPI package.
Turnstone ships several parallel release tracks from a single PyPI package.
## Release Tracks
| Track | Versions | Branch | Docker tags | PyPI install |
|-------|----------|--------|-------------|--------------|
| **Stable** | `1.0.0`, `1.0.1` | `stable/1.0` | `:1.0.1`, `:1.0`, `:stable`, `:latest` | `pip install turnstone` |
| **Experimental** | `1.1.0a1`, `1.1.0a2` | `main` | `:1.1.0a1`, `:experimental` | `pip install turnstone --pre` |
| **Legacy 1.0** | `1.0.x` | `stable/1.0` | `:1.0.x`, `:1.0` | `pip install 'turnstone==1.0.*'` |
| **Stable 1.3** | `1.3.x` | `stable/1.3` | `:1.3.x`, `:1.3` | `pip install 'turnstone==1.3.*'` |
| **Stable 1.4** | `1.4.x` | `stable/1.4` | `:1.4.x`, `:1.4`, `:stable`, `:latest` | `pip install turnstone` |
| **Experimental** | `1.5.0aN` | `main` | `:1.5.0aN`, `:experimental` | `pip install turnstone --pre` |
- **Stable** receives bugfixes only. Production-grade.
- **Experimental** receives new features. May be rough around the edges.
- When experimental matures, it is promoted to stable. The previous stable branch stops receiving patches.
- **Stable** tracks receive bugfixes only. The most-recent stable minor
owns the `:stable` / `:latest` Docker tags and the default PyPI
install.
- **Experimental** (always on `main`) receives new features. May be
rough around the edges.
- When experimental matures, it is promoted to a new stable minor via
a `stable/X.Y` branch; older stable branches continue to receive
security fixes until explicitly retired.
## Version Scheme
@@ -26,17 +33,17 @@ Turnstone uses two parallel release tracks published from a single PyPI package.
## Releasing an Experimental Version (from main)
```bash
scripts/release.sh 1.1.0a2 --push
scripts/release.sh 1.5.0a2 --push
```
This bumps `pyproject.toml` + `turnstone/__init__.py`, regenerates `uv.lock`, commits, tags `v1.1.0a2`, and pushes. CI runs, then publish + Docker workflows fire automatically.
This bumps `pyproject.toml` + `turnstone/__init__.py`, regenerates `uv.lock`, commits, tags `v1.5.0a2`, and pushes. CI runs, then publish + Docker workflows fire automatically.
## Releasing a Stable Patch (from stable/X.Y)
```bash
git checkout stable/1.0
git checkout stable/1.4
git cherry-pick <commit-hash> # bugfix from main
scripts/release.sh 1.0.2 --push
scripts/release.sh 1.4.1 --push
```
## Promoting Experimental to Stable
@@ -45,17 +52,19 @@ When `main` is ready for a stable release:
```bash
# 1. Tag the stable release on main
scripts/release.sh 1.1.0 --push
scripts/release.sh 1.5.0 --push
# 2. Create the stable maintenance branch from that tag
git branch stable/1.1 v1.1.0
git push origin stable/1.1
git branch stable/1.5 v1.5.0
git push origin stable/1.5
# 3. Start the next experimental cycle on main
scripts/release.sh 1.2.0a1 --push
scripts/release.sh 1.6.0a1 --push
```
The previous `stable/1.0` branch stops receiving patches at this point.
The previous stable branch (`stable/1.4`) continues to receive
security-only patches; older tracks (`stable/1.0`, `stable/1.3`) are
retired when they fall out of support.
## CI/CD Pipeline
+36 -2
View File
@@ -69,8 +69,12 @@ Both `TurnstoneServer` (sync) and `AsyncTurnstoneServer` (async) expose:
|----------|--------|---------|
| **Workstreams** | `list_workstreams()` | `ListWorkstreamsResponse` |
| | `dashboard()` | `DashboardResponse` |
| | `create_workstream(*, name, model, auto_approve, skill)` | `CreateWorkstreamResponse` |
| | `create_workstream(*, name, model, auto_approve, skill, initial_message, attachments)` | `CreateWorkstreamResponse` |
| | `close_workstream(ws_id)` | `StatusResponse` |
| **Attachments** | `upload_attachment(ws_id, filename, data, *, mime_type=...)` | `UploadAttachmentResponse` |
| | `list_attachments(ws_id)` | `ListAttachmentsResponse` |
| | `get_attachment_content(ws_id, attachment_id)` | `bytes` |
| | `delete_attachment(ws_id, attachment_id)` | `StatusResponse` |
| **Chat** | `send(message, ws_id)` | `SendResponse` |
| | `approve(*, ws_id, approved, feedback, always)` | `StatusResponse` |
| | `plan_feedback(*, ws_id, feedback)` | `StatusResponse` |
@@ -171,6 +175,36 @@ result.ok # True if no errors and not timed out
result.timed_out # True if timeout expired
```
### Attachments
Upload files to a workstream and attach them to the next user turn:
```python
# Upload separately, then send a message — attachments auto-attach
with open("screenshot.png", "rb") as f:
att = client.upload_attachment(ws.ws_id, "screenshot.png",
f.read(),
mime_type="image/png")
client.send("What's wrong in this screenshot?", ws.ws_id)
# Or attach at workstream-creation time (multipart upload)
from turnstone.sdk import AttachmentUpload
with open("notes.txt", "rb") as f:
ws = client.create_workstream(
name="triage",
initial_message="Summarize the notes",
attachments=[AttachmentUpload(data=f.read(),
filename="notes.txt",
mime_type="text/plain")],
)
```
Limits: images ≤ 4 MiB (png/jpeg/gif/webp), text ≤ 512 KiB (UTF-8),
10 pending per (workstream, user). The SDK auto-generates `ws_id` on the
client so cluster-routed callers bind attachments to the owning node
before the request lands.
### Error Handling
Non-2xx responses raise `TurnstoneAPIError`:
@@ -284,7 +318,7 @@ turnstone/sdk/ Python SDK (sub-package)
_base.py Shared httpx async client, auth, error handling
_sync.py Background event loop for sync wrappers
_types.py TurnResult + TurnstoneAPIError
events.py 27 SSE event dataclasses with type registry
events.py 38 SSE event dataclasses with type registry
server.py AsyncTurnstoneServer + TurnstoneServer
console.py AsyncTurnstoneConsole + TurnstoneConsole
+11 -8
View File
@@ -1,8 +1,10 @@
# Security and Authentication
Turnstone uses a layered authentication system with three token types,
hierarchical scopes, and a split architecture where the console manages
credentials while individual server nodes validate JWTs locally.
Turnstone uses a layered authentication system with two token types
(database-backed API tokens + HMAC-SHA256 JWTs), hierarchical scopes,
and a split architecture where the console manages credentials while
individual server nodes validate JWTs locally. Inter-service traffic
uses short-lived service JWTs minted by `ServiceTokenManager`.
---
@@ -37,7 +39,7 @@ Claims:
|-------|-------------|
| `sub` | User ID |
| `scopes` | Comma-separated scope list (`read,write,approve`) |
| `src` | Token source (`password`, `api_token`, `config`, `oidc`) |
| `src` | Token source (`password`, `database`, `oidc`, or a service origin like `console`, `cli`, or `channel`) |
| `iss` | Issuer — always `turnstone` |
| `aud` | Audience — `turnstone-server` or `turnstone-console` |
| `iat` | Issued-at timestamp |
@@ -65,10 +67,11 @@ Scopes are hierarchical — higher scopes imply all lower ones.
| Method | Path pattern | Required scope |
|--------|-------------|----------------|
| GET | Any protected path | `read` |
| POST | `/api/send`, `/api/plan`, `/api/command` | `write` |
| POST | `/api/workstreams/new`, `/api/workstreams/close` | `write` |
| POST | `/api/cluster/workstreams/new` | `write` |
| POST | `/api/approve` | `approve` |
| POST | `/api/plan`, `/api/command` | `write` |
| POST | `/api/workstreams/new`, `/api/cluster/workstreams/new` | `write` |
| POST | `/api/workstreams/{ws_id}/{send,cancel,close,delete,open,refresh-title,title,attachments}` | `write` |
| DELETE | `/api/workstreams/{ws_id}/send` (dequeue), `/api/workstreams/{ws_id}/attachments/{attachment_id}` | `write` |
| POST | `/api/workstreams/{ws_id}/approve` | `approve` |
| Any | `/api/admin/*` | `approve` |
Public paths bypass authentication entirely: `/`, `/health`, `/metrics`,
+46 -4
View File
@@ -36,6 +36,47 @@ users to the admin Settings API.
---
## Per-Model Sampling Overrides
The global `model.temperature`, `model.max_tokens`, and `model.reasoning_effort`
settings serve as cluster-wide defaults. Individual models can override these
via per-model settings in the `model_definitions` table (admin Models tab).
Resolution order for sampling parameters:
| Priority | Source |
|----------|--------|
| 1 (highest) | Per-model override (set in Models tab) |
| 2 | Global default (set in Settings tab) |
| 3 | Registry default (code) |
When a per-model override is `NULL` (empty in the UI), the global default is
used. Switching models via `/model <alias>` re-resolves sampling parameters
from the new model's overrides or global defaults.
**Removed settings:** `model.name` and `model.context_window` have been removed
from ConfigStore. Model names and context windows are now configured per-model
in the Models tab. A startup warning is logged if these keys appear in
`config.toml`.
### Plan / task agent overrides
`plan_agent` and `task_agent` sub-sessions resolve independently from the
conversation model so operators can pick a cheaper/faster model for
autonomous loops:
| Setting | Purpose |
|---------|---------|
| `model.plan_alias` | Alias used for `plan_agent` sub-sessions. Falls back to `[model].plan_model` in config.toml, then `[model].agent_model`, then the session's active model. |
| `model.task_alias` | Alias used for `task_agent` sub-sessions. Same fallback chain as `plan_alias`. |
| `model.plan_effort` | Reasoning effort for `plan_agent` (`none` / `minimal` / `low` / `medium` / `high` / `xhigh` / `max`). Defaults to `high`. |
| `model.task_effort` | Reasoning effort for `task_agent`. Empty string means "inherit from the session". |
All four are live-editable from the Settings tab and take effect on the
next sub-agent invocation — no restart required.
---
## Bootstrap vs ConfigStore
**Bootstrap settings** are required before storage is available (database
@@ -49,12 +90,12 @@ connection, Redis, auth secrets, server bind address). These stay in
| Auth | `[auth]` | config.toml / env |
| Console bind | `[console]` | config.toml / env |
**ConfigStore settings** (48 settings) are loaded from the database after
storage initialization:
**ConfigStore settings** are loaded from the database after storage
initialization:
| Section | Settings |
|---------|----------|
| `model` | name, temperature, max_tokens, reasoning_effort, context_window |
| `model` | default_alias, temperature, max_tokens, reasoning_effort, plan_alias, task_alias, plan_effort, task_effort |
| `session` | instructions, retention_days, compact_max_tokens, auto_compact_pct |
| `tools` | timeout, truncation, agent_max_turns, skip_permissions, search, search_threshold, search_max_results |
| `server` | workstream_idle_timeout, max_workstreams |
@@ -62,7 +103,8 @@ storage initialization:
| `mcp` | config_path, refresh_interval, registry_url |
| `ratelimit` | enabled, requests_per_second, burst, trusted_proxies |
| `health` | backend_probe_interval, backend_probe_timeout, circuit_breaker_threshold, circuit_breaker_cooldown |
| `judge` | enabled, model, provider, base_url, api_key, confidence_threshold, max_context_ratio, timeout, read_only_tools, output_guard, redact_secrets |
| `judge` | enabled, model, provider, base_url, api_key, confidence_threshold, max_context_ratio, timeout, read_only_tools, output_guard, redact_secrets, cancel_on_approval |
| `interface` | close_tab_action, theme |
| `skills` | discovery_url |
| `memory` | relevance_k, fetch_limit, max_content, nudge_cooldown, nudges |
+11 -8
View File
@@ -169,8 +169,8 @@ Every tool defines a `primary_key`. The mapping is:
| `man` | `page` |
| `web_fetch` | `url` |
| `web_search` | `query` |
| `task` | `prompt` |
| `plan` | `prompt` |
| `task_agent` | `prompt` |
| `plan_agent` | `goal` |
| `memory` | `name` |
| `recall` | `query` |
| `notify` | `message` |
@@ -357,7 +357,10 @@ Search the web using a text query.
## Agent
### task
Tool names use the `_agent` suffix — bare `plan` / `task` collide with
chat-template channel names on some local models.
### task_agent
Delegate a general-purpose task to an autonomous sub-agent.
@@ -371,7 +374,7 @@ Delegate a general-purpose task to an autonomous sub-agent.
---
### plan
### plan_agent
Plan before implementing -- an autonomous agent explores the codebase and writes a structured plan.
@@ -543,11 +546,11 @@ pre-configure skills at workstream creation.
- `load` — Activate a skill by name. Calls `set_skill()` which handles content
rendering with `{{model}}`/`{{ws_id}}`/`{{node_id}}` variables, system message
reinitialization, and config persistence. Returns the skill name, description,
and security scan tier. Warns on high/critical scan status.
and security risk level. Warns on high/critical risk level.
- `search` — Find available skills by query. Uses BM25 relevance ranking over
name, description, tags, and category (same `BM25Index` used by memory
relevance and tool search). Returns up to 10 results with name, description,
category, scan status, and activation type.
category, risk level, and activation type.
- **Auto-approve**: `load` requires approval (changes session behavior); `search`
is auto-approved (read-only).
@@ -568,8 +571,8 @@ pre-configure skills at workstream creation.
| `man` | Info | Yes | Yes | Yes | `page` |
| `web_fetch` | Info | No | Yes | Yes | `url` |
| `web_search` | Info | No | Yes | Yes | `query` |
| `task` | Agent | No | No | No | `prompt` |
| `plan` | Agent | No | No | No | `prompt` |
| `task_agent` | Agent | No | No | No | `prompt` |
| `plan_agent` | Agent | No | No | No | `goal` |
| `memory` | Memory | Yes | No | No | `name` |
| `recall` | Memory | Yes | No | No | `query` |
| `notify` | Notify | Yes | Yes | Yes | `message` |
+38 -1
View File
@@ -2,11 +2,48 @@
An MCP server that exposes tools for executing commands across a Turnstone cluster. Serves as a reference implementation for both MCP server patterns and Turnstone SDK usage.
> [!NOTE]
> **Superseded by the built-in coordinator workstream in Turnstone 1.5.**
>
> This MCP side-car is the pre-1.5 pattern for cluster-wide orchestration.
> Turnstone 1.5 promotes coordinator behaviour to a first-class workstream
> kind hosted inside `turnstone-console` — no external MCP server to
> install or operate, proper per-user audit attribution, and a dedicated
> UI at `/coordinator/{ws_id}`.
>
> The extension continues to work for 1.4-and-earlier clusters. On 1.5+:
> grant the `admin.coordinator` permission, set `coordinator.model_alias`
> in the admin Settings tab, and create sessions via the dashboard's
> "new coordinator" button or `POST /v1/api/coordinator/new`. Full
> removal of this example (including docker / compose references) is
> planned once 1.5 is confirmed in production.
>
> | Concern | Built-in coordinator (1.5+) | This MCP extension (1.4-and-earlier) |
> |---|---|---|
> | Install | None — shipped in-tree | `pip install -e examples/mcp-cluster-ops` + MCP client config |
> | Auth | Real creator's `user_id` + `admin.coordinator` permission | Shared service token |
> | Audit | `coordinator.create` / `close` / `cancel` events on the console; `src="coordinator"` preserved on upstream hops | Service identity only |
> | UI | `/coordinator/{ws_id}` one-pane HTML | No UI — model-only |
> | Tool approvals | Inline approval bar in the coordinator pane | MCP approval flow |
> | Configuration | `coordinator.model_alias`, `coordinator.max_active`, `coordinator.reasoning_effort`, `coordinator.session_jwt_ttl_seconds` | MCP server config file |
>
> Minimal 1.5 migration:
>
> ```bash
> curl -X POST https://console.example/v1/api/coordinator/new \
> -H "Authorization: Bearer $TOKEN" \
> -H "Content-Type: application/json" \
> -d '{"name":"planner","initial_message":"Spawn a worker to check the build"}'
> ```
>
> The response carries `ws_id`; open
> `https://console.example/coordinator/{ws_id}` to watch the session.
## How it works
This server uses the Turnstone console SDK (`TurnstoneConsole`) for node discovery and routing, and `TurnstoneServer` for per-node SSE streaming. The dispatch flow for each command is:
1. **Route**`TurnstoneConsole.route_create_workstream(target_node=..., auto_approve=True)` creates a workstream pinned to the target node via the console's hash-ring routing proxy, returning `ws_id` and `node_url`.
1. **Route**`TurnstoneConsole.route_create_workstream(target_node=..., auto_approve=True)` creates a workstream pinned to the target node via the console's rendezvous routing proxy, returning `ws_id` and `node_url`.
2. **Execute**`TurnstoneServer(node_url, token=...)` connects directly to the node's SSE stream using the same `TURNSTONE_API_TOKEN`. `send_and_wait(prompt, ws_id)` runs the command and the raw bash output is captured from the `ToolResultEvent` — bypassing the costly "agent reads output then re-generates output as completion tokens" round-trip.
3. **Cleanup**`TurnstoneConsole.route_close(ws_id)` closes the workstream.
+14 -7
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "turnstone"
version = "1.0.2"
version = "1.5.0"
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
readme = "README.md"
license = "BUSL-1.1"
@@ -44,16 +44,17 @@ Repository = "https://github.com/turnstonelabs/turnstone"
Issues = "https://github.com/turnstonelabs/turnstone/issues"
[project.optional-dependencies]
test = ["pytest>=9.0", "pytest-cov>=6.0", "croniter>=3.0"]
test = ["pytest>=9.0", "pytest-cov>=6.0", "croniter>=3.0", "slack-bolt>=1.18", "aiohttp>=3.9"]
dev = ["ruff>=0.9", "mypy>=1.14"]
console = ["croniter>=3.0"]
anthropic = ["anthropic>=0.39"]
postgres = ["psycopg[binary]>=3.2"]
ddg = ["ddgs>=9.0"]
discord = ["discord.py>=2.4"]
tls = ["lacme>=1.0.4"]
tls = ["lacme>=1.0.5"]
sandbox = ["sympy>=1.13", "numpy>=2.0", "scipy>=1.14", "pytest>=9.0"]
all = ["turnstone[console,anthropic,postgres,discord,ddg,tls,sandbox]"]
slack = ["slack-bolt>=1.18", "aiohttp>=3.9"]
all = ["turnstone[console,anthropic,postgres,discord,ddg,tls,sandbox,slack]"]
[project.scripts]
turnstone = "turnstone.cli:main"
@@ -75,12 +76,17 @@ include = [
"turnstone/console/static/*.html",
"turnstone/console/static/*.css",
"turnstone/console/static/*.js",
"turnstone/console/static/coordinator/*.html",
"turnstone/console/static/coordinator/*.css",
"turnstone/console/static/coordinator/*.js",
"turnstone/shared_static/*.css",
"turnstone/shared_static/*.js",
"turnstone/shared_static/katex-0.16.44/**/*",
"turnstone/shared_static/katex-0.16.45/**/*",
"turnstone/shared_static/hljs-11.11.1/**/*",
"turnstone/shared_static/mermaid-11.14.0/**/*",
"turnstone/shared_static/hls-1.6.16/**/*",
"turnstone/sdk/py.typed",
"turnstone/deploy/*.yaml",
]
[tool.pytest.ini_options]
@@ -179,5 +185,6 @@ disallow_untyped_decorators = false
warn_unused_ignores = false
[[tool.mypy.overrides]]
module = "tests.*"
disallow_untyped_defs = false
module = ["slack_bolt", "slack_bolt.*", "slack_sdk", "slack_sdk.*"]
ignore_missing_imports = true
disallow_untyped_calls = false
File diff suppressed because it is too large Load Diff
+32 -1
View File
@@ -5,6 +5,7 @@
# scripts/update-vendored-js.sh katex 0.16.39
# scripts/update-vendored-js.sh hljs 11.12.0
# scripts/update-vendored-js.sh mermaid 11.14.0
# scripts/update-vendored-js.sh hls 1.6.15
#
# This script:
# 1. Downloads the new version from CDN
@@ -18,7 +19,7 @@ STATIC_DIR="turnstone/shared_static"
CDN="https://cdn.jsdelivr.net/npm"
usage() {
echo "Usage: $0 <katex|hljs|mermaid> <version>"
echo "Usage: $0 <katex|hljs|mermaid|hls> <version>"
echo "Example: $0 katex 0.16.39"
exit 1
}
@@ -147,12 +148,42 @@ case "$LIB" in
echo "Done. Old directory removed: ${OLD_DIR}"
;;
hls)
OLD_VERSION=$(detect_old_version "hls")
check_same_version "$OLD_VERSION" "$VERSION" "hls"
OLD_DIR="${STATIC_DIR}/hls-${OLD_VERSION}"
NEW_DIR="${STATIC_DIR}/hls-${VERSION}"
echo "Updating hls.js ${OLD_VERSION} -> ${VERSION}"
mkdir -p "${NEW_DIR}"
echo " Downloading hls.min.js..."
curl -sSfL "${CDN}/hls.js@${VERSION}/dist/hls.min.js" -o "${NEW_DIR}/hls.min.js"
echo " Downloading LICENSE..."
if ! curl -sSfL "${CDN}/hls.js@${VERSION}/LICENSE" -o "${NEW_DIR}/LICENSE" 2>/dev/null; then
if [[ -f "${OLD_DIR}/LICENSE" ]]; then
cp "${OLD_DIR}/LICENSE" "${NEW_DIR}/LICENSE"
else
echo " WARNING: Could not obtain LICENSE for hls.js ${VERSION}"
fi
fi
update_refs "hls-${OLD_VERSION}" "hls-${VERSION}"
rm -rf "${OLD_DIR}"
echo "Done. Old directory removed: ${OLD_DIR}"
;;
*)
echo "Unknown library: ${LIB}"
usage
;;
esac
echo ""
echo "NOTE: If you added a NEW library (not just updating a version), also update"
echo " the _ASSET_RE regex in turnstone/core/web_helpers.py — its negative lookahead"
echo " skips vendored directories to avoid double-versioning static asset URLs."
echo ""
echo "Verify the update:"
echo " git diff --stat"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+166 -159
View File
@@ -1,12 +1,12 @@
{
"name": "@turnstone/sdk",
"version": "0.3.0",
"version": "0.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@turnstone/sdk",
"version": "0.3.0",
"version": "0.4.0",
"license": "BUSL-1.1",
"devDependencies": {
"typescript": "^6.0.0",
@@ -14,38 +14,35 @@
}
},
"node_modules/@emnapi/core": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz",
"integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==",
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.0",
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz",
"integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==",
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz",
"integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==",
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
@@ -58,9 +55,9 @@
"license": "MIT"
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz",
"integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
"integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -77,9 +74,9 @@
}
},
"node_modules/@oxc-project/types": {
"version": "0.122.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz",
"integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==",
"version": "0.127.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz",
"integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==",
"dev": true,
"license": "MIT",
"funding": {
@@ -87,9 +84,9 @@
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz",
"integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz",
"integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==",
"cpu": [
"arm64"
],
@@ -104,9 +101,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz",
"integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz",
"integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==",
"cpu": [
"arm64"
],
@@ -121,9 +118,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz",
"integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz",
"integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==",
"cpu": [
"x64"
],
@@ -138,9 +135,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz",
"integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz",
"integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==",
"cpu": [
"x64"
],
@@ -155,9 +152,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz",
"integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz",
"integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==",
"cpu": [
"arm"
],
@@ -172,9 +169,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz",
"integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz",
"integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==",
"cpu": [
"arm64"
],
@@ -192,9 +189,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz",
"integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz",
"integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==",
"cpu": [
"arm64"
],
@@ -212,9 +209,9 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz",
"integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz",
"integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==",
"cpu": [
"ppc64"
],
@@ -232,9 +229,9 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz",
"integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz",
"integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==",
"cpu": [
"s390x"
],
@@ -252,9 +249,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz",
"integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz",
"integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==",
"cpu": [
"x64"
],
@@ -272,9 +269,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz",
"integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz",
"integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==",
"cpu": [
"x64"
],
@@ -292,9 +289,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz",
"integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz",
"integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==",
"cpu": [
"arm64"
],
@@ -309,9 +306,9 @@
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz",
"integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz",
"integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==",
"cpu": [
"wasm32"
],
@@ -319,16 +316,18 @@
"license": "MIT",
"optional": true,
"dependencies": {
"@napi-rs/wasm-runtime": "^1.1.1"
"@emnapi/core": "1.10.0",
"@emnapi/runtime": "1.10.0",
"@napi-rs/wasm-runtime": "^1.1.4"
},
"engines": {
"node": ">=14.0.0"
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz",
"integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz",
"integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==",
"cpu": [
"arm64"
],
@@ -343,9 +342,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz",
"integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz",
"integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==",
"cpu": [
"x64"
],
@@ -360,9 +359,9 @@
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz",
"integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz",
"integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==",
"dev": true,
"license": "MIT"
},
@@ -410,16 +409,16 @@
"license": "MIT"
},
"node_modules/@vitest/expect": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz",
"integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==",
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz",
"integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.2",
"@vitest/utils": "4.1.2",
"@vitest/spy": "4.1.5",
"@vitest/utils": "4.1.5",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
@@ -428,13 +427,13 @@
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz",
"integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==",
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz",
"integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.2",
"@vitest/spy": "4.1.5",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
@@ -455,9 +454,9 @@
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz",
"integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==",
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz",
"integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -468,13 +467,13 @@
}
},
"node_modules/@vitest/runner": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz",
"integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==",
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz",
"integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.2",
"@vitest/utils": "4.1.5",
"pathe": "^2.0.3"
},
"funding": {
@@ -482,14 +481,14 @@
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz",
"integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==",
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz",
"integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.2",
"@vitest/utils": "4.1.2",
"@vitest/pretty-format": "4.1.5",
"@vitest/utils": "4.1.5",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
@@ -498,9 +497,9 @@
}
},
"node_modules/@vitest/spy": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz",
"integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==",
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz",
"integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==",
"dev": true,
"license": "MIT",
"funding": {
@@ -508,13 +507,13 @@
}
},
"node_modules/@vitest/utils": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz",
"integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==",
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz",
"integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.2",
"@vitest/pretty-format": "4.1.5",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
@@ -560,9 +559,9 @@
}
},
"node_modules/es-module-lexer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
"integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz",
"integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==",
"dev": true,
"license": "MIT"
},
@@ -960,9 +959,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
"version": "8.5.12",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz",
"integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==",
"dev": true,
"funding": [
{
@@ -989,14 +988,14 @@
}
},
"node_modules/rolldown": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz",
"integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz",
"integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.122.0",
"@rolldown/pluginutils": "1.0.0-rc.12"
"@oxc-project/types": "=0.127.0",
"@rolldown/pluginutils": "1.0.0-rc.17"
},
"bin": {
"rolldown": "bin/cli.mjs"
@@ -1005,21 +1004,21 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.0-rc.12",
"@rolldown/binding-darwin-arm64": "1.0.0-rc.12",
"@rolldown/binding-darwin-x64": "1.0.0-rc.12",
"@rolldown/binding-freebsd-x64": "1.0.0-rc.12",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12",
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12",
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12",
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12",
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.12",
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.12",
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.12",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12",
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12"
"@rolldown/binding-android-arm64": "1.0.0-rc.17",
"@rolldown/binding-darwin-arm64": "1.0.0-rc.17",
"@rolldown/binding-darwin-x64": "1.0.0-rc.17",
"@rolldown/binding-freebsd-x64": "1.0.0-rc.17",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17",
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17",
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17",
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17",
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.17",
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.17",
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.17",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17",
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17"
}
},
"node_modules/siginfo": {
@@ -1047,9 +1046,9 @@
"license": "MIT"
},
"node_modules/std-env": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz",
"integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==",
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
"integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
"dev": true,
"license": "MIT"
},
@@ -1061,9 +1060,9 @@
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz",
"integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz",
"integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1071,14 +1070,14 @@
}
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.3"
"picomatch": "^4.0.4"
},
"engines": {
"node": ">=12.0.0"
@@ -1106,9 +1105,9 @@
"optional": true
},
"node_modules/typescript": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz",
"integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -1120,17 +1119,17 @@
}
},
"node_modules/vite": {
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz",
"integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==",
"version": "8.0.10",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz",
"integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
"postcss": "^8.5.8",
"rolldown": "1.0.0-rc.12",
"tinyglobby": "^0.2.15"
"postcss": "^8.5.10",
"rolldown": "1.0.0-rc.17",
"tinyglobby": "^0.2.16"
},
"bin": {
"vite": "bin/vite.js"
@@ -1147,7 +1146,7 @@
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.1.0",
"esbuild": "^0.27.0",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
"sass": "^1.70.0",
@@ -1198,19 +1197,19 @@
}
},
"node_modules/vitest": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz",
"integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==",
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz",
"integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.2",
"@vitest/mocker": "4.1.2",
"@vitest/pretty-format": "4.1.2",
"@vitest/runner": "4.1.2",
"@vitest/snapshot": "4.1.2",
"@vitest/spy": "4.1.2",
"@vitest/utils": "4.1.2",
"@vitest/expect": "4.1.5",
"@vitest/mocker": "4.1.5",
"@vitest/pretty-format": "4.1.5",
"@vitest/runner": "4.1.5",
"@vitest/snapshot": "4.1.5",
"@vitest/spy": "4.1.5",
"@vitest/utils": "4.1.5",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
@@ -1238,10 +1237,12 @@
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.2",
"@vitest/browser-preview": "4.1.2",
"@vitest/browser-webdriverio": "4.1.2",
"@vitest/ui": "4.1.2",
"@vitest/browser-playwright": "4.1.5",
"@vitest/browser-preview": "4.1.5",
"@vitest/browser-webdriverio": "4.1.5",
"@vitest/coverage-istanbul": "4.1.5",
"@vitest/coverage-v8": "4.1.5",
"@vitest/ui": "4.1.5",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
@@ -1265,6 +1266,12 @@
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/coverage-istanbul": {
"optional": true
},
"@vitest/coverage-v8": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@turnstone/sdk",
"version": "0.3.0",
"version": "0.4.0",
"description": "TypeScript client SDK for the turnstone AI orchestration platform",
"type": "module",
"main": "./dist/index.js",
+69 -16
View File
@@ -29,6 +29,12 @@ export interface ClientOptions {
export interface RequestOptions {
json?: object;
params?: Record<string, string | number>;
/**
* When set, send as multipart form-data with this body. The runtime's
* fetch sets the Content-Type + boundary itself, so we deliberately do
* not include a Content-Type header in this case.
*/
form?: FormData;
}
export class BaseClient {
@@ -47,36 +53,34 @@ export class BaseClient {
path: string,
options?: RequestOptions,
): Promise<T> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
const headers: Record<string, string> = {};
if (!options?.form) {
headers["Content-Type"] = "application/json";
}
if (this.token) {
headers["Authorization"] = `Bearer ${this.token}`;
}
let url = `${this.baseUrl}${path}`;
if (options?.params) {
const searchParams = new URLSearchParams();
for (const [key, value] of Object.entries(options.params)) {
if (value !== undefined && value !== "") {
searchParams.set(key, String(value));
}
}
const qs = searchParams.toString();
if (qs) url += `?${qs}`;
const url = this._buildUrl(path, options?.params);
let body: BodyInit | undefined;
if (options?.form) {
body = options.form;
} else if (options?.json) {
body = JSON.stringify(options.json);
}
const resp = await this.fetchFn(url, {
method,
headers,
body: options?.json ? JSON.stringify(options.json) : undefined,
body,
});
if (!resp.ok) {
let msg = "";
try {
const body = (await resp.json()) as Record<string, unknown>;
msg = (body.error as string) ?? (body.detail as string) ?? "";
const errBody = (await resp.json()) as Record<string, unknown>;
msg = (errBody.error as string) ?? (errBody.detail as string) ?? "";
} catch {
msg = await resp.text().catch(() => "");
}
@@ -86,6 +90,55 @@ export class BaseClient {
return (await resp.json()) as T;
}
protected async requestBytes(
method: string,
path: string,
options?: { params?: Record<string, string | number> },
): Promise<{ bytes: Uint8Array; contentType: string; filename: string }> {
const headers: Record<string, string> = {};
if (this.token) {
headers["Authorization"] = `Bearer ${this.token}`;
}
const url = this._buildUrl(path, options?.params);
const resp = await this.fetchFn(url, { method, headers });
if (!resp.ok) {
let msg = "";
try {
const errBody = (await resp.json()) as Record<string, unknown>;
msg = (errBody.error as string) ?? (errBody.detail as string) ?? "";
} catch {
msg = await resp.text().catch(() => "");
}
throw new TurnstoneAPIError(resp.status, msg || `HTTP ${resp.status}`);
}
const contentType =
resp.headers.get("content-type") ?? "application/octet-stream";
const disposition = resp.headers.get("content-disposition") ?? "";
const match = /filename="?([^";]+)"?/.exec(disposition);
const filename = match ? match[1] : "";
const buf = await resp.arrayBuffer();
return { bytes: new Uint8Array(buf), contentType, filename };
}
private _buildUrl(
path: string,
params?: Record<string, string | number>,
): string {
let url = `${this.baseUrl}${path}`;
if (params) {
const searchParams = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== "") {
searchParams.set(key, String(value));
}
}
const qs = searchParams.toString();
if (qs) url += `?${qs}`;
}
return url;
}
protected async *streamSSE<T = Record<string, unknown>>(
path: string,
params?: Record<string, string | number>,
+116
View File
@@ -4,6 +4,8 @@ import type {
AdminListMemoriesOptions,
AdminMemoryInfo,
AdminSearchMemoriesOptions,
AttachmentContent,
AttachmentUpload,
AuditQueryOptions,
AuditResponse,
AuthLoginResponse,
@@ -16,6 +18,9 @@ import type {
ConsoleCreateWsRequest,
ConsoleCreateWsResponse,
ConsoleHealthResponse,
CreateWorkstreamRequest,
CreateWorkstreamResponse,
ListAttachmentsResponse,
CreateMcpServerRequest,
CreatePolicyOptions,
CreateRoleOptions,
@@ -55,12 +60,37 @@ import type {
UpdateScheduleRequest,
UpdateSettingOptions,
UpdateSkillRequest,
UploadAttachmentResponse,
UsageQueryOptions,
UsageResponse,
UserRoleInfo,
WorkstreamsOptions,
} from "./types.js";
function generateConsoleWsId(): string {
// 16 bytes => 32 hex chars; matches `secrets.token_hex(16)` server-side.
const buf = new Uint8Array(16);
crypto.getRandomValues(buf);
return Array.from(buf, (b) => b.toString(16).padStart(2, "0")).join("");
}
function consoleAttachmentToBlob(att: AttachmentUpload): Blob {
if (att.data instanceof Blob) {
return att.mimeType
? new Blob([att.data], { type: att.mimeType })
: att.data;
}
// Copy bytes into a fresh ArrayBuffer-backed Uint8Array. The Blob
// BlobPart type rejects ArrayBufferLike views (could be backed by
// SharedArrayBuffer); a freshly allocated buffer is plainly ArrayBuffer.
const src = att.data;
const fresh = new Uint8Array(new ArrayBuffer(src.byteLength));
fresh.set(src);
return new Blob([fresh], {
type: att.mimeType ?? "application/octet-stream",
});
}
/** Async client for the turnstone console API. */
export class TurnstoneConsole extends BaseClient {
constructor(options: ClientOptions) {
@@ -113,6 +143,92 @@ export class TurnstoneConsole extends BaseClient {
});
}
// -- Routing proxy --------------------------------------------------------
/**
* Create a workstream via the console rendezvous router.
*
* When `attachments` is non-empty the request is sent as
* multipart/form-data and the console routes via `?ws_id=<hex>`
* (auto-generated when not supplied) so the body lands on the
* owning node directly.
*/
async routeCreateWorkstream(
opts?: CreateWorkstreamRequest & { target_node?: string },
): Promise<
CreateWorkstreamResponse & { node_url?: string; node_id?: string }
> {
const attachments = opts?.attachments;
if (attachments && attachments.length > 0) {
// The console's multipart route_create routes by `?ws_id=` only —
// it does not parse the body to honor `target_node`. Refuse the
// combination at the SDK boundary so callers don't silently get
// routed to the wrong node.
if (opts?.target_node) {
throw new Error(
"target_node is not supported with attachments; " +
"use ws_id (caller-generated to hash to the desired node) instead",
);
}
const meta: Record<string, unknown> = { ...opts };
delete (meta as { attachments?: unknown }).attachments;
let wsId = (meta.ws_id as string | undefined) ?? "";
if (!wsId) {
wsId = generateConsoleWsId();
meta.ws_id = wsId;
}
const form = new FormData();
form.append("meta", JSON.stringify(meta));
for (const att of attachments) {
form.append("file", consoleAttachmentToBlob(att), att.filename);
}
return this.request("POST", "/v1/api/route/workstreams/new", {
form,
params: { ws_id: wsId },
});
}
return this.request("POST", "/v1/api/route/workstreams/new", {
json: opts ?? {},
});
}
async routeUploadAttachment(
wsId: string,
file: AttachmentUpload,
): Promise<UploadAttachmentResponse> {
const form = new FormData();
form.append("file", consoleAttachmentToBlob(file), file.filename);
return this.request(
"POST",
`/v1/api/route/workstreams/${wsId}/attachments`,
{ form },
);
}
async routeListAttachments(wsId: string): Promise<ListAttachmentsResponse> {
return this.request("GET", `/v1/api/route/workstreams/${wsId}/attachments`);
}
async routeGetAttachmentContent(
wsId: string,
attachmentId: string,
): Promise<AttachmentContent> {
return this.requestBytes(
"GET",
`/v1/api/route/workstreams/${wsId}/attachments/${attachmentId}/content`,
);
}
async routeDeleteAttachment(
wsId: string,
attachmentId: string,
): Promise<StatusResponse> {
return this.request(
"DELETE",
`/v1/api/route/workstreams/${wsId}/attachments/${attachmentId}`,
);
}
// -- Streaming ------------------------------------------------------------
async *clusterEvents(): AsyncIterableIterator<ClusterEvent> {
+10
View File
@@ -92,6 +92,11 @@ export interface PlanReviewEvent {
content: string;
}
export interface PlanResolvedEvent {
type: "plan_resolved";
feedback: string;
}
export interface InfoEvent {
type: "info";
message: string;
@@ -165,6 +170,7 @@ export type ServerEvent =
| ToolOutputChunkEvent
| StatusEvent
| PlanReviewEvent
| PlanResolvedEvent
| InfoEvent
| ErrorEvent
| BusyErrorEvent
@@ -283,6 +289,10 @@ export function isPlanReviewEvent(e: ServerEvent): e is PlanReviewEvent {
return e.type === "plan_review";
}
export function isPlanResolvedEvent(e: ServerEvent): e is PlanResolvedEvent {
return e.type === "plan_resolved";
}
export function isCancelledEvent(e: ServerEvent): e is CancelledEvent {
return e.type === "cancelled";
}
+6
View File
@@ -183,6 +183,12 @@ export type {
SkillInstallRequest,
SkillInstallResponse,
SkillInstallSkipped,
// Attachment types
AttachmentUpload,
AttachmentInfo,
UploadAttachmentResponse,
ListAttachmentsResponse,
AttachmentContent,
} from "./types.js";
// SSE parser (for advanced usage)
+132 -21
View File
@@ -1,6 +1,8 @@
import { BaseClient, type ClientOptions } from "./base.js";
import type { ServerEvent } from "./events.js";
import type {
AttachmentContent,
AttachmentUpload,
AuthLoginResponse,
AuthSetupResponse,
AuthStatusResponse,
@@ -9,20 +11,46 @@ import type {
DashboardResponse,
DeleteMemoryOptions,
HealthResponse,
ListAttachmentsResponse,
ListMemoriesOptions,
ListMemoriesResponse,
ListSavedWorkstreamsResponse,
SkillSummary,
ListWorkstreamsResponse,
MemoryInfo,
SaveMemoryRequest,
SearchMemoriesRequest,
SendAndWaitOptions,
SendResponse,
SkillSummary,
StatusResponse,
TurnResult,
UploadAttachmentResponse,
} from "./types.js";
function generateWsId(): string {
// 16 bytes => 32 hex chars; matches `secrets.token_hex(16)` server-side.
const buf = new Uint8Array(16);
crypto.getRandomValues(buf);
return Array.from(buf, (b) => b.toString(16).padStart(2, "0")).join("");
}
function attachmentToBlob(att: AttachmentUpload): Blob {
if (att.data instanceof Blob) {
return att.mimeType
? new Blob([att.data], { type: att.mimeType })
: att.data;
}
// Copy bytes into a fresh ArrayBuffer-backed Uint8Array. The Blob
// BlobPart type rejects ArrayBufferLike views (could be backed by
// SharedArrayBuffer); a freshly allocated buffer is plainly ArrayBuffer.
const src = att.data;
const fresh = new Uint8Array(new ArrayBuffer(src.byteLength));
fresh.set(src);
return new Blob([fresh], {
type: att.mimeType ?? "application/octet-stream",
});
}
/** Async client for the turnstone server API. */
export class TurnstoneServer extends BaseClient {
constructor(options: ClientOptions) {
@@ -42,37 +70,114 @@ export class TurnstoneServer extends BaseClient {
async createWorkstream(
opts?: CreateWorkstreamRequest,
): Promise<CreateWorkstreamResponse> {
return this.request("POST", "/v1/api/workstreams/new", { json: opts });
const attachments = opts?.attachments;
if (attachments && attachments.length > 0) {
// Multipart variant: pre-generate ws_id so cluster routers can
// hash to the owning node before this body lands. Server accepts
// either a server-generated id (when meta.ws_id is empty) or the
// caller-supplied one.
const meta: Record<string, unknown> = { ...opts };
delete (meta as { attachments?: unknown }).attachments;
if (!meta.ws_id) {
meta.ws_id = generateWsId();
}
const form = new FormData();
form.append("meta", JSON.stringify(meta));
for (const att of attachments) {
form.append("file", attachmentToBlob(att), att.filename);
}
return this.request("POST", "/v1/api/workstreams/new", { form });
}
return this.request("POST", "/v1/api/workstreams/new", {
json: opts ?? {},
});
}
async closeWorkstream(wsId: string): Promise<StatusResponse> {
return this.request("POST", "/v1/api/workstreams/close", {
json: { ws_id: wsId },
});
async closeWorkstream(
wsId: string,
opts?: { reason?: string },
): Promise<StatusResponse> {
const body: Record<string, unknown> = {};
if (opts?.reason !== undefined) body.reason = opts.reason;
return this.request(
"POST",
`/v1/api/workstreams/${encodeURIComponent(wsId)}/close`,
{ json: body },
);
}
// -- Chat interaction -----------------------------------------------------
async send(message: string, wsId: string): Promise<SendResponse> {
return this.request("POST", "/v1/api/send", {
json: { message, ws_id: wsId },
async send(
message: string,
wsId: string,
opts?: { attachmentIds?: string[] },
): Promise<SendResponse> {
const body: Record<string, unknown> = { message };
if (opts?.attachmentIds !== undefined) {
body.attachment_ids = opts.attachmentIds;
}
return this.request(
"POST",
`/v1/api/workstreams/${encodeURIComponent(wsId)}/send`,
{ json: body },
);
}
// -- Attachments ----------------------------------------------------------
async uploadAttachment(
wsId: string,
file: AttachmentUpload,
): Promise<UploadAttachmentResponse> {
const form = new FormData();
form.append("file", attachmentToBlob(file), file.filename);
return this.request("POST", `/v1/api/workstreams/${wsId}/attachments`, {
form,
});
}
async listAttachments(wsId: string): Promise<ListAttachmentsResponse> {
return this.request("GET", `/v1/api/workstreams/${wsId}/attachments`);
}
async getAttachmentContent(
wsId: string,
attachmentId: string,
): Promise<AttachmentContent> {
return this.requestBytes(
"GET",
`/v1/api/workstreams/${wsId}/attachments/${attachmentId}/content`,
);
}
async deleteAttachment(
wsId: string,
attachmentId: string,
): Promise<StatusResponse> {
return this.request(
"DELETE",
`/v1/api/workstreams/${wsId}/attachments/${attachmentId}`,
);
}
async approve(opts: {
wsId: string;
approved?: boolean;
feedback?: string | null;
always?: boolean;
}): Promise<StatusResponse> {
return this.request("POST", "/v1/api/approve", {
json: {
ws_id: opts.wsId,
approved: opts.approved ?? true,
feedback: opts.feedback,
always: opts.always,
return this.request(
"POST",
`/v1/api/workstreams/${encodeURIComponent(opts.wsId)}/approve`,
{
json: {
approved: opts.approved ?? true,
feedback: opts.feedback,
always: opts.always,
},
},
});
);
}
async planFeedback(opts: {
@@ -97,15 +202,21 @@ export class TurnstoneServer extends BaseClient {
wsId: string,
opts?: { force?: boolean },
): Promise<StatusResponse> {
const body: Record<string, unknown> = { ws_id: wsId };
const body: Record<string, unknown> = {};
if (opts?.force) body.force = true;
return this.request("POST", "/v1/api/cancel", { json: body });
return this.request(
"POST",
`/v1/api/workstreams/${encodeURIComponent(wsId)}/cancel`,
{ json: body },
);
}
// -- Streaming ------------------------------------------------------------
async *streamEvents(wsId: string): AsyncIterableIterator<ServerEvent> {
yield* this.streamSSE<ServerEvent>("/v1/api/events", { ws_id: wsId });
yield* this.streamSSE<ServerEvent>(
`/v1/api/workstreams/${encodeURIComponent(wsId)}/events`,
);
}
async *streamGlobalEvents(): AsyncIterableIterator<ServerEvent> {
@@ -145,8 +256,8 @@ export class TurnstoneServer extends BaseClient {
try {
// Start consuming the per-workstream SSE stream first
const events = this.streamSSE<ServerEvent>(
"/v1/api/events",
{ ws_id: wsId },
`/v1/api/workstreams/${encodeURIComponent(wsId)}/events`,
undefined,
controller.signal,
);
+107 -5
View File
@@ -50,10 +50,67 @@ export interface AuthSetupResponse {
export interface SendRequest {
message: string;
ws_id: string;
/**
* Explicit list of pending attachment ids to inject into this turn.
* When omitted, any pending attachments for the caller on the
* workstream are auto-consumed; an empty list disables auto-consume.
*/
attachment_ids?: string[];
}
export interface SendResponse {
/** "ok" | "busy" | "queued" | "queue_full". */
status: string;
/**
* Attachment ids actually reserved onto this turn. Subset of the
* request's `attachment_ids` (or the auto-consumed pending set).
*/
attached_ids?: string[];
/**
* Attachment ids the caller requested that the server could not
* reserve (lost a race, already consumed, or cross-scope). The
* request still proceeds with whatever was reserved.
*/
dropped_attachment_ids?: string[];
/** Set on "queued" responses: relative priority of the queued message. */
priority?: string | null;
/** Set on "queued" responses: id used to dequeue the message. */
msg_id?: string | null;
}
// ---------------------------------------------------------------------------
// Server API — Attachments
// ---------------------------------------------------------------------------
/** A file to upload as an attachment. */
export interface AttachmentUpload {
filename: string;
/** Raw file bytes; use a `Blob` in browsers and a `Uint8Array` in Node. */
data: Blob | Uint8Array;
/** Optional advisory MIME type; the server applies its own validation. */
mimeType?: string;
}
export interface AttachmentInfo {
attachment_id: string;
filename: string;
mime_type: string;
size_bytes: number;
/** "image" or "text". */
kind: string;
}
export type UploadAttachmentResponse = AttachmentInfo;
export interface ListAttachmentsResponse {
attachments: AttachmentInfo[];
}
/** Raw bytes returned from the attachment `/content` endpoint. */
export interface AttachmentContent {
bytes: Uint8Array;
contentType: string;
filename: string;
}
export interface ApproveRequest {
@@ -79,6 +136,20 @@ export interface CreateWorkstreamRequest {
auto_approve?: boolean;
resume_ws?: string;
skill?: string;
/** First user message dispatched in a background worker after creation. */
initial_message?: string;
/**
* Caller-supplied workstream id (32-hex). Auto-generated when omitted.
* Required for cluster-routed multipart creates so the console can
* hash to the owning node before the body lands.
*/
ws_id?: string;
/**
* Files to attach to the first turn. When non-empty the request is
* sent as multipart/form-data and (with `initial_message`) reserved
* onto that turn before the worker dispatches.
*/
attachments?: AttachmentUpload[];
}
export interface CreateWorkstreamResponse {
@@ -86,24 +157,56 @@ export interface CreateWorkstreamResponse {
name: string;
resumed?: boolean;
message_count?: number;
/** Ids of attachments saved by this request (multipart variant only). */
attachment_ids?: string[];
}
export interface CloseWorkstreamRequest {
ws_id: string;
/**
* Optional close reason persisted to `workstream_config` for
* postmortem. Capped at 512 UTF-8 bytes server-side; credential
* redaction is applied via the output guard.
*/
reason?: string;
}
export interface WorkstreamInfo {
id: string;
// Renamed `id` → `ws_id` and added kind/parent_ws_id/user_id in
// the Stage 2 list-verb lift. Pre-1.5 readers branching on
// `row.id` should swap to `row.ws_id`.
ws_id: string;
name: string;
state: string;
kind: string;
parent_ws_id: string | null;
user_id: string;
}
export interface ListWorkstreamsResponse {
workstreams: WorkstreamInfo[];
}
export interface WorkstreamDetailResponse {
// Lifted from coord-only into a shared verb in the Stage 2
// history/detail verb lift. Both kinds populate every field; SDK
// consumers don't branch on kind.
ws_id: string;
name: string;
state: string;
user_id: string;
kind: string;
}
export interface WorkstreamHistoryResponse {
ws_id: string;
// Tail of the workstream's reconstructed message history
// (provider-fidelity OpenAI-like shape). Bounded by the ?limit=
// query param (default 100, max 500).
messages: Record<string, unknown>[];
}
export interface DashboardWorkstream {
id: string;
ws_id: string;
name: string;
state: string;
title?: string;
@@ -284,7 +387,6 @@ export interface CreateSkillResourceRequest {
export interface BackendStatus {
status: string;
circuit_state: string;
}
export interface WorkstreamCounts {
@@ -880,7 +982,7 @@ export interface SkillDiscoverListing {
install_count: number;
tags: string[];
installed: boolean;
scan_status?: string;
risk_level?: string;
template_id?: string;
}
+22
View File
@@ -62,6 +62,28 @@ describe("TurnstoneConsole", () => {
expect(url).toContain("page=2");
});
it("routeCreateWorkstream rejects attachments + target_node", async () => {
const fetchFn = vi.fn().mockResolvedValue(
new Response("{}", {
status: 500,
headers: { "content-type": "application/json" },
}),
);
const client = new TurnstoneConsole({
baseUrl: "http://test",
fetch: fetchFn,
});
const data = new TextEncoder().encode("hi");
await expect(
client.routeCreateWorkstream({
name: "x",
target_node: "n1",
attachments: [{ filename: "a.txt", data }],
}),
).rejects.toThrow(/target_node/);
expect(fetchFn).not.toHaveBeenCalled();
});
it("health returns parsed response", async () => {
const fetchFn = mockFetch({
status: "ok",
+6
View File
@@ -8,6 +8,7 @@ import {
isApproveRequestEvent,
isApprovalResolvedEvent,
isPlanReviewEvent,
isPlanResolvedEvent,
isReasoningEvent,
} from "../src/events.js";
import type { ServerEvent } from "../src/events.js";
@@ -76,4 +77,9 @@ describe("event type guards", () => {
const e: ServerEvent = { type: "plan_review", content: "## Plan" };
expect(isPlanReviewEvent(e)).toBe(true);
});
it("isPlanResolvedEvent", () => {
const e: ServerEvent = { type: "plan_resolved", feedback: "approved" };
expect(isPlanResolvedEvent(e)).toBe(true);
});
});
@@ -0,0 +1,168 @@
import { describe, expect, it, vi } from "vitest";
import { TurnstoneServer } from "../src/server.js";
function mockFetch(response: object, status = 200): typeof globalThis.fetch {
return vi.fn().mockResolvedValue(
new Response(JSON.stringify(response), {
status,
headers: { "content-type": "application/json" },
}),
);
}
function mockFetchBytes(
body: Uint8Array,
contentType: string,
filename = "",
): typeof globalThis.fetch {
const headers: Record<string, string> = { "content-type": contentType };
if (filename)
headers["content-disposition"] = `inline; filename="${filename}"`;
return vi
.fn()
.mockResolvedValue(new Response(body, { status: 200, headers }));
}
describe("TurnstoneServer attachments", () => {
it("uploadAttachment sends multipart with filename", async () => {
const fetchFn = mockFetch({
attachment_id: "att-1",
filename: "a.txt",
mime_type: "text/plain",
size_bytes: 5,
kind: "text",
});
const client = new TurnstoneServer({
baseUrl: "http://test",
fetch: fetchFn,
});
const data = new TextEncoder().encode("hello");
const result = await client.uploadAttachment("ws-X", {
filename: "a.txt",
data,
mimeType: "text/plain",
});
expect(result.attachment_id).toBe("att-1");
const [url, init] = (fetchFn as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toBe("http://test/v1/api/workstreams/ws-X/attachments");
expect(init.method).toBe("POST");
expect(init.body).toBeInstanceOf(FormData);
// Browser/Node fetch sets the Content-Type header from FormData itself
expect(init.headers["Content-Type"]).toBeUndefined();
});
it("listAttachments hits the GET endpoint", async () => {
const fetchFn = mockFetch({ attachments: [] });
const client = new TurnstoneServer({
baseUrl: "http://test",
fetch: fetchFn,
});
const resp = await client.listAttachments("ws-X");
expect(resp.attachments).toEqual([]);
const [url, init] = (fetchFn as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toBe("http://test/v1/api/workstreams/ws-X/attachments");
expect(init.method).toBe("GET");
});
it("getAttachmentContent returns raw bytes + parsed headers", async () => {
const bytes = new TextEncoder().encode("hello world");
const fetchFn = mockFetchBytes(
bytes,
"text/plain; charset=utf-8",
"notes.md",
);
const client = new TurnstoneServer({
baseUrl: "http://test",
fetch: fetchFn,
});
const result = await client.getAttachmentContent("ws-X", "att-1");
expect(new TextDecoder().decode(result.bytes)).toBe("hello world");
expect(result.contentType).toBe("text/plain; charset=utf-8");
expect(result.filename).toBe("notes.md");
});
it("deleteAttachment hits the DELETE endpoint", async () => {
const fetchFn = mockFetch({ status: "deleted" });
const client = new TurnstoneServer({
baseUrl: "http://test",
fetch: fetchFn,
});
const resp = await client.deleteAttachment("ws-X", "att-1");
expect(resp.status).toBe("deleted");
const [, init] = (fetchFn as ReturnType<typeof vi.fn>).mock.calls[0];
expect(init.method).toBe("DELETE");
});
it("send threads attachment_ids when provided", async () => {
const fetchFn = mockFetch({ status: "ok" });
const client = new TurnstoneServer({
baseUrl: "http://test",
fetch: fetchFn,
});
await client.send("hi", "ws-X", { attachmentIds: ["a1", "a2"] });
const [, init] = (fetchFn as ReturnType<typeof vi.fn>).mock.calls[0];
expect(JSON.parse(init.body)).toEqual({
message: "hi",
ws_id: "ws-X",
attachment_ids: ["a1", "a2"],
});
});
it("send omits attachment_ids when not supplied", async () => {
const fetchFn = mockFetch({ status: "ok" });
const client = new TurnstoneServer({
baseUrl: "http://test",
fetch: fetchFn,
});
await client.send("hi", "ws-X");
const [, init] = (fetchFn as ReturnType<typeof vi.fn>).mock.calls[0];
expect(JSON.parse(init.body)).toEqual({ message: "hi", ws_id: "ws-X" });
});
it("createWorkstream with attachments sends multipart and auto-generates ws_id", async () => {
const fetchFn = mockFetch({
ws_id: "00ff00000000000000000000000000ff",
name: "demo",
attachment_ids: ["att-1"],
});
const client = new TurnstoneServer({
baseUrl: "http://test",
fetch: fetchFn,
});
const data = new TextEncoder().encode("hello");
const resp = await client.createWorkstream({
name: "demo",
initial_message: "describe",
attachments: [{ filename: "a.txt", data, mimeType: "text/plain" }],
});
expect(resp.attachment_ids).toEqual(["att-1"]);
const [url, init] = (fetchFn as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toBe("http://test/v1/api/workstreams/new");
expect(init.method).toBe("POST");
expect(init.body).toBeInstanceOf(FormData);
const form = init.body as FormData;
const meta = JSON.parse(form.get("meta") as string);
expect(meta.name).toBe("demo");
expect(meta.initial_message).toBe("describe");
expect(meta.ws_id).toMatch(/^[0-9a-f]{32}$/);
expect(meta.attachments).toBeUndefined();
const file = form.get("file");
expect(file).toBeInstanceOf(Blob);
});
it("createWorkstream without attachments uses JSON body", async () => {
const fetchFn = mockFetch({ ws_id: "ws-json", name: "j" });
const client = new TurnstoneServer({
baseUrl: "http://test",
fetch: fetchFn,
});
await client.createWorkstream({ name: "j" });
const [, init] = (fetchFn as ReturnType<typeof vi.fn>).mock.calls[0];
expect(init.headers["Content-Type"]).toBe("application/json");
expect(JSON.parse(init.body)).toEqual({ name: "j" });
});
});
+13 -2
View File
@@ -26,7 +26,16 @@ function mockFetchError(
describe("TurnstoneServer", () => {
it("listWorkstreams returns parsed response", async () => {
const fetchFn = mockFetch({
workstreams: [{ id: "ws1", name: "test", state: "idle" }],
workstreams: [
{
ws_id: "ws1",
name: "test",
state: "idle",
kind: "interactive",
parent_ws_id: null,
user_id: "u1",
},
],
});
const client = new TurnstoneServer({
baseUrl: "http://test",
@@ -34,7 +43,9 @@ describe("TurnstoneServer", () => {
});
const resp = await client.listWorkstreams();
expect(resp.workstreams).toHaveLength(1);
expect(resp.workstreams[0].id).toBe("ws1");
// Row key renamed id → ws_id in the Stage 2 list-verb lift.
expect(resp.workstreams[0].ws_id).toBe("ws1");
expect(resp.workstreams[0].kind).toBe("interactive");
expect(fetchFn).toHaveBeenCalledWith(
"http://test/v1/api/workstreams",
expect.objectContaining({ method: "GET" }),
+130
View File
@@ -0,0 +1,130 @@
"""Shared builders for the coordinator-endpoint test files.
The four coordinator test modules each ship a copy of the same
``_AuthMiddleware`` / ``_FakeConfigStore`` / ``_fake_registry`` /
``_build_mgr`` helpers this module is the single home for them so
future edits land once. Named with a leading underscore so pytest
does not collect it.
``_make_client`` stays local to each test module because the route
list differs per file.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from unittest.mock import MagicMock
from starlette.middleware.base import BaseHTTPMiddleware
from turnstone.console.collector import ClusterCollector
from turnstone.console.coordinator_adapter import CoordinatorAdapter
from turnstone.console.coordinator_ui import ConsoleCoordinatorUI
from turnstone.core.auth import AuthResult
from turnstone.core.session_manager import SessionManager
if TYPE_CHECKING:
from collections.abc import Iterable
def _seed_children(
adapter: CoordinatorAdapter, coord_ws_id: str, child_ws_ids: Iterable[str]
) -> None:
"""Seed the coordinator adapter's children registry directly.
The production path populates the registry via the cluster-event
fan-out thread observing ``ws_created`` events. These tests just
need a known-children set for the endpoint handlers to iterate
inject directly under ``_children_lock`` rather than spinning up
the collector + fan-out plumbing.
"""
with adapter._children_lock:
adapter._merge_child_ids_locked(coord_ws_id, child_ws_ids)
class _AuthMiddleware(BaseHTTPMiddleware):
"""Inject a configurable AuthResult from a header-based contract.
Tests set ``X-Test-Perms`` to a comma-separated permission list, and
``X-Test-User`` to the user id. Empty or missing no auth.
"""
async def dispatch(self, request, call_next): # type: ignore[no-untyped-def]
perms = request.headers.get("X-Test-Perms", "")
user_id = request.headers.get("X-Test-User", "")
if perms or user_id:
request.state.auth_result = AuthResult(
user_id=user_id,
scopes=frozenset({"approve"}),
token_source="test",
permissions=frozenset(p for p in perms.split(",") if p),
)
return await call_next(request)
class _FakeConfigStore:
"""Minimal ConfigStore stub — returns values from a dict."""
def __init__(self, values: dict[str, Any]) -> None:
self._values = values
def get(self, key: str, default: Any = None) -> Any:
return self._values.get(key, default)
def _fake_registry() -> MagicMock:
"""MagicMock whose ``.resolve()`` succeeds so the 503 gate passes."""
reg = MagicMock()
reg.resolve.return_value = (MagicMock(), "gpt-4", MagicMock())
return reg
def _build_mgr_with_factory(storage: Any, session_factory: Any) -> SessionManager:
"""Build a SessionManager(CoordinatorAdapter) with a caller-supplied factory.
Used by tests that need to capture or assert factory kwargs (e.g.
per-call ``model`` / ``judge_model`` overrides). Plain :func:`_build_mgr`
is the right entry point when the test doesn't care about the
factory.
"""
adapter = CoordinatorAdapter(
collector=MagicMock(),
ui_factory=lambda ws: ConsoleCoordinatorUI(ws_id=ws.id, user_id=ws.user_id or ""),
session_factory=session_factory,
)
mgr = SessionManager(
adapter,
storage=storage,
max_active=3,
node_id=ClusterCollector.CONSOLE_PSEUDO_NODE_ID,
event_emitter=adapter,
)
adapter.attach(mgr)
return mgr
def _build_mgr(storage: Any) -> SessionManager:
"""Build a SessionManager(CoordinatorAdapter) with stub factories (test default)."""
def _sf(ui, model_alias=None, ws_id=None, **kw): # type: ignore[no-untyped-def]
s = MagicMock()
s.send.return_value = None
return s
return _build_mgr_with_factory(storage, _sf)
class MockStorage:
"""Minimal storage mock that implements ``list_services``.
Used by the collector tests + the console route-walk tests. The
collector calls ``list_services("turnstone-server", ...)`` to
discover nodes; tests that don't care about discovery push an
empty list (the default).
"""
def __init__(self) -> None:
self.services: list[dict[str, str]] = []
def list_services(self, service_type: str, max_age_seconds: int = 120) -> list[dict[str, str]]:
return list(self.services)
+58
View File
@@ -0,0 +1,58 @@
"""Shared mock factory for ``events_replay`` tests.
Both interactive (:func:`turnstone.server._interactive_events_replay`)
and coord (:func:`turnstone.console.server._coord_events_replay`) drive
the same shared preamble at
:func:`turnstone.core.session_replay.session_replay_preamble`. Their
test suites share the underlying mock surface (session.model,
session.model_alias, session._last_usage, ui._pending_*, ui._ws_lock,
counters); this module is the single home for that shape so a future
field add lands once.
"""
from __future__ import annotations
import threading
from typing import Any
from unittest.mock import MagicMock
def make_replay_mocks(
*,
last_usage: dict[str, Any] | None = None,
**ui_overrides: Any,
) -> tuple[Any, Any, Any]:
"""Build ``(ws, ui, request)`` MagicMocks for events-replay tests.
Defaults match a fresh workstream that hasn't completed a turn
(no ``last_usage``, no pending prompts).
Args:
last_usage: Sets ``ws.session._last_usage`` directly so tests
don't have to reach into the nested mock; when ``None``
(default), the status replay branch stays inert.
**ui_overrides: Additional attributes set directly on the ``ui``
mock (e.g. ``_pending_approval``, ``_pending_plan_review``,
``_llm_verdicts``, ``_ws_turn_tool_calls``, ``_ws_messages``).
"""
session = MagicMock()
session.model = "gpt-5"
session.model_alias = "default"
session._last_usage = last_usage
session.context_window = 100000
session.reasoning_effort = "medium"
session.messages = []
ui = MagicMock()
ui.auto_approve = False
ui._pending_approval = None
ui._pending_plan_review = None
ui._llm_verdicts = {}
ui._ws_lock = threading.Lock()
ui._ws_turn_tool_calls = 0
ui._ws_messages = 0
for key, value in ui_overrides.items():
setattr(ui, key, value)
ws = MagicMock()
ws.session = session
request = MagicMock()
return ws, ui, request
+18
View File
@@ -95,3 +95,21 @@ def mock_openai_client():
client = MagicMock()
client.models.list.return_value.data = [MagicMock(id="test-model")]
return client
@pytest.fixture(autouse=True)
def _clear_policy_cache():
"""Drop the in-process tool-policy cache between tests.
The cache is keyed by org_id (default ``""``), so without this
autouse hook a policy created in test A would leak into test B's
``evaluate_tool_policy`` call distinct storage instances, same
cache slot. Production singleton storage doesn't see the leak
because there's only one storage instance for the process lifetime;
the test isolation requirement is what motivates the autouse.
"""
from turnstone.core.policy import invalidate_policy_cache
invalidate_policy_cache()
yield
invalidate_policy_cache()
+2 -2
View File
@@ -49,7 +49,7 @@ class TestServerVersioning:
mock_mgr = MagicMock()
mock_mgr.list_all.return_value = []
mock_mgr.max_workstreams = 10
mock_mgr.max_active = 10
app = create_app(
workstreams=mock_mgr,
global_queue=queue.Queue(),
@@ -76,7 +76,7 @@ class TestServerVersioning:
assert resp.status_code == 200
spec = resp.json()
assert spec["openapi"] == "3.1.0"
assert "/v1/api/send" in spec["paths"]
assert "/v1/api/workstreams/{ws_id}/send" in spec["paths"]
def test_docs_page(self, client):
resp = client.get("/docs")
+94
View File
@@ -0,0 +1,94 @@
"""Static smoke guards for ``turnstone/ui/static/app.js``.
The interactive WebUI's app.js has no JS test framework on the
project side. This file holds Python-side string-presence assertions
that catch regressions on critical paths the kind of one-line
deletion or rename that breaks the UI silently and only surfaces in
manual testing.
"""
from __future__ import annotations
import re
from pathlib import Path
_APP_JS = Path(__file__).resolve().parent.parent / "turnstone/ui/static/app.js"
def test_switch_tab_bootstraps_pane_when_none_exists() -> None:
"""``switchTab`` must create a pane when none exists. A fresh-
loaded interactive UI with no workstreams shows the dashboard
and creates no panes (per ``initWorkstreams``); the user's first
``create`` or ``open`` then calls ``switchTab(newWsId)``. Pre-fix,
the early ``if (!pane) return;`` left switchTab with nowhere to
attach the chat UI never connected SSE for the freshly-created
workstream, and only a page refresh fixed it. This test guards
against accidentally re-introducing the early-return."""
body = _APP_JS.read_text(encoding="utf-8")
start = body.index("function switchTab(wsId) {")
# Bound the search to the function body — switchTab is short.
fn = body[start : start + 2000]
assert "if (!pane) return;" not in fn, (
"switchTab must not early-return when no pane exists — that's "
"the no-chat-after-first-create bug. Bootstrap a pane instead."
)
# Affirmatively check the bootstrap path exists.
assert "createPane(wsId)" in fn, (
"switchTab must call createPane(wsId) to bootstrap the first "
"pane when getFocusedPane returns null"
)
def test_tool_error_does_not_overwrite_approval_badge() -> None:
"""When an approved tool subsequently errors, the existing
`` approved`` (or `` auto-approved``) pill must remain visible
the error indicator is appended as a sibling pill, not by mutating
the approval pill in place. Pre-fix, both ``appendToolOutput``
(live) and ``replayHistory`` (history reconstruction) located the
existing approval badge via ``querySelector(".ts-approval-badge")``
and overwrote its className + textContent with the ``--error``
state, so the user lost the record that they had approved the
call. This test pins the new append-sibling behaviour."""
body = _APP_JS.read_text(encoding="utf-8")
# Affirmatively check that an idempotency guard exists somewhere:
# a ``querySelector(".ts-approval-badge--error")`` lookup is the
# structural marker of the fix. Pre-fix the modifier never appeared
# in app.js at all. Loose on quote style and surrounding form (the
# guard might be a negated ``if (!q) {build...}`` block at a call
# site, or a positive ``if (q) return;`` early-exit inside an
# extracted helper) so a later refactor doesn't trip CI on
# cosmetics.
error_guard_re = re.compile(
r"""querySelector\(\s*['"]\.ts-approval-badge--error['"]\s*\)""",
)
assert error_guard_re.search(body), (
"The error-badge code path must guard creation with a "
"querySelector for .ts-approval-badge--error so duplicate fires "
"(live + history re-render) do not stack badges."
)
# Forbid the mutate-existing-badge sequence: a generic
# ``.ts-approval-badge`` lookup followed within a handful of lines
# by mutating that same handle into the ``--error`` state. Two
# unrelated call sites (history rendering + live tool-output
# insertion) legitimately query ``.ts-approval-badge`` to position
# output above it, so the bare query alone is not the anti-pattern;
# the close pairing with an ``--error`` class mutation is. Accept
# either quote style and catch both ``className = "..."`` and
# ``classList.add("ts-approval-badge--error")`` forms.
overwrite_re = re.compile(
r"""(\w+)\s*=\s*\w+\.querySelector\(\s*(["'])\.ts-approval-badge\2\s*\)\s*;"""
r""".{0,200}?"""
r"""(?:"""
r"""\1\.className\s*=\s*(["'])[^"']*\bts-approval-badge--error\b[^"']*\3"""
r"""|"""
r"""\1\.classList\.add\([^)]*(["'])ts-approval-badge--error\4[^)]*\)"""
r""")""",
re.DOTALL,
)
assert not overwrite_re.search(body), (
"Found the badge-overwrite anti-pattern: a queried "
".ts-approval-badge handle is mutated into the --error variant "
"(via className overwrite or classList.add). Append a sibling "
"badge instead so the approval verdict stays visible alongside "
"the error."
)
+152
View File
@@ -56,3 +56,155 @@ def test_record_audit_generates_unique_ids(storage):
events = storage.list_audit_events()
assert len(events) == 2
assert events[0]["event_id"] != events[1]["event_id"]
# ---------------------------------------------------------------------------
# Credential redaction at the audit boundary
# ---------------------------------------------------------------------------
def test_record_audit_redacts_passwords_by_default(storage):
"""Detail strings go through redact_credentials by default."""
record_audit(
storage,
"u1",
"coordinator.spawn",
detail={
"initial_message": "connect via postgresql://alice:s3cret@db.example.com/app",
},
)
event = storage.list_audit_events()[0]
detail = json.loads(event["detail"])
# Exact redaction text comes from output_guard._redact_credentials —
# assert the token is stripped rather than the exact marker so
# this test doesn't break if the marker format evolves.
assert "s3cret" not in detail["initial_message"]
assert "REDACTED" in detail["initial_message"]
def test_record_audit_redacts_nested_strings(storage):
"""Walker descends into lists / nested dicts."""
record_audit(
storage,
"u1",
"tasks.update",
detail={
"tasks": [
{"title": "normal task"},
{"title": "pull secret from AWS_SECRET_ACCESS_KEY=AKIAEXAMPLE123"},
],
},
)
event = storage.list_audit_events()[0]
detail = json.loads(event["detail"])
assert detail["tasks"][0]["title"] == "normal task"
assert "AKIAEXAMPLE123" not in detail["tasks"][1]["title"]
def test_record_audit_raw_detail_preserves_payload(storage):
"""`raw_detail=True` bypasses the scrub — operator-originated detail only."""
secret_like = "postgresql://alice:s3cret@db.example.com/app"
record_audit(
storage,
"admin-1",
"investigation.note",
detail={"note": secret_like},
raw_detail=True,
)
event = storage.list_audit_events()[0]
detail = json.loads(event["detail"])
assert detail["note"] == secret_like
def test_record_audit_strips_control_chars(storage):
"""CR/LF/NUL/DEL and C0 controls are replaced with spaces so a
downstream exporter that prints raw detail strings can't re-surface
log-injection. Tab/newline are deliberately preserved."""
record_audit(
storage,
"u1",
"coordinator.note",
detail={
"msg": "hello\r\nInjected: bad\x00 escape \x1b[31mred\x1b[0m\x7f",
"ok_tab": "a\tb\nc",
},
)
event = storage.list_audit_events()[0]
detail = json.loads(event["detail"])
# CR / NUL / ESC / DEL scrubbed to spaces; tab + newline kept.
assert "\r" not in detail["msg"]
assert "\x00" not in detail["msg"]
assert "\x1b" not in detail["msg"]
assert "\x7f" not in detail["msg"]
assert "hello" in detail["msg"]
assert detail["ok_tab"] == "a\tb\nc"
def test_record_audit_clean_strings_roundtrip_unchanged(storage):
"""Detail strings with no credential patterns and no control chars
pass through unchanged the fast-path / scrub must not corrupt the
common case."""
clean = {"note": "hello world", "code": "import foo", "state": "ok"}
record_audit(storage, "u1", "coordinator.note", detail=clean)
event = storage.list_audit_events()[0]
assert json.loads(event["detail"]) == clean
def test_record_audit_fast_path_skips_no_string_detail(storage):
"""A detail carrying only scalars (no strings anywhere) must persist
identically exercises the ``_has_any_string`` fast path."""
record_audit(
storage,
"u1",
"coordinator.metric",
detail={"spawned": 5, "ok": True, "parent": None, "tail": [1, 2, 3]},
)
event = storage.list_audit_events()[0]
assert json.loads(event["detail"]) == {
"spawned": 5,
"ok": True,
"parent": None,
"tail": [1, 2, 3],
}
def test_record_audit_redacts_dict_keys(storage):
"""Walker descends into dict keys too — a caller using
model-controlled text as a key can't leak it verbatim."""
record_audit(
storage,
"u1",
"coordinator.note",
detail={"postgresql://alice:s3cret@db.example.com/app": True},
)
event = storage.list_audit_events()[0]
detail = json.loads(event["detail"])
assert all("s3cret" not in k for k in detail)
def test_record_audit_walks_set_and_frozenset(storage):
"""Walker handles set/frozenset values (docstring promise)."""
record_audit(
storage,
"u1",
"coordinator.note",
detail={"tags": frozenset({"ak_" + "x" * 40, "plain"})},
)
event = storage.list_audit_events()[0]
detail = json.loads(event["detail"])
# The credential-looking AK token gets scrubbed; the plain one survives.
tags = detail["tags"]
assert "plain" in tags
def test_record_audit_leaves_non_string_scalars_alone(storage):
"""Non-string scalars (int / bool / None) pass through unchanged."""
record_audit(
storage,
"u1",
"coordinator.spawn",
detail={"budget_ok": True, "spawned": 5, "parent": None},
)
event = storage.list_audit_events()[0]
detail = json.loads(event["detail"])
assert detail == {"budget_ok": True, "spawned": 5, "parent": None}
+454 -43
View File
@@ -14,6 +14,7 @@ from turnstone.core.auth import (
check_request,
create_jwt,
is_public_path,
load_jwt_secret,
make_clear_cookie,
make_set_cookie,
required_scope,
@@ -52,8 +53,8 @@ class TestIsPublicPath:
def test_api_workstreams_not_public(self):
assert is_public_path("/api/workstreams") is False
def test_api_send_not_public(self):
assert is_public_path("/api/send") is False
def test_api_workstreams_send_not_public(self):
assert is_public_path("/api/workstreams/abc/send") is False
def test_api_cluster_overview_not_public(self):
assert is_public_path("/api/cluster/overview") is False
@@ -70,8 +71,8 @@ class TestIsPublicPath:
def test_v1_api_workstreams_not_public(self):
assert is_public_path("/v1/api/workstreams") is False
def test_v1_api_send_not_public(self):
assert is_public_path("/v1/api/send") is False
def test_v1_api_workstreams_send_not_public(self):
assert is_public_path("/v1/api/workstreams/abc/send") is False
def test_openapi_json_public(self):
assert is_public_path("/openapi.json") is True
@@ -96,10 +97,22 @@ class TestRequiredScope:
assert required_scope("GET", "/api/events") == "read"
def test_post_send_needs_write(self):
assert required_scope("POST", "/api/send") == "write"
assert required_scope("POST", "/api/workstreams/abc/send") == "write"
def test_delete_send_needs_write(self):
assert required_scope("DELETE", "/api/workstreams/abc/send") == "write"
def test_post_approve_needs_approve(self):
assert required_scope("POST", "/api/approve") == "approve"
assert required_scope("POST", "/api/workstreams/abc/approve") == "approve"
def test_post_cancel_needs_write(self):
assert required_scope("POST", "/api/workstreams/abc/cancel") == "write"
def test_post_close_needs_write(self):
assert required_scope("POST", "/api/workstreams/abc/close") == "write"
def test_get_events_per_ws_needs_read(self):
assert required_scope("GET", "/api/workstreams/abc/events") == "read"
def test_post_plan_needs_write(self):
assert required_scope("POST", "/api/plan") == "write"
@@ -110,9 +123,6 @@ class TestRequiredScope:
def test_post_workstreams_new_needs_write(self):
assert required_scope("POST", "/api/workstreams/new") == "write"
def test_post_workstreams_close_needs_write(self):
assert required_scope("POST", "/api/workstreams/close") == "write"
def test_all_write_paths_need_write(self):
for path in WRITE_PATHS:
scope = required_scope("POST", path)
@@ -122,10 +132,10 @@ class TestRequiredScope:
assert required_scope("POST", "/api/unknown") == "read"
def test_v1_post_send_needs_write(self):
assert required_scope("POST", "/v1/api/send") == "write"
assert required_scope("POST", "/v1/api/workstreams/abc/send") == "write"
def test_v1_post_approve_needs_approve(self):
assert required_scope("POST", "/v1/api/approve") == "approve"
assert required_scope("POST", "/v1/api/workstreams/abc/approve") == "approve"
def test_v1_get_workstreams_needs_read(self):
assert required_scope("GET", "/v1/api/workstreams") == "read"
@@ -134,10 +144,10 @@ class TestRequiredScope:
assert required_scope("POST", "/v1/api/cluster/workstreams/new") == "write"
def test_proxy_v1_send_needs_write(self):
assert required_scope("POST", "/node/node-a/v1/api/send") == "write"
assert required_scope("POST", "/node/node-a/v1/api/workstreams/abc/send") == "write"
def test_proxy_v1_approve_needs_approve(self):
assert required_scope("POST", "/node/node-a/v1/api/approve") == "approve"
assert required_scope("POST", "/node/node-a/v1/api/workstreams/abc/approve") == "approve"
def test_proxy_v1_read_endpoint_needs_read(self):
assert required_scope("GET", "/node/node-a/v1/api/workstreams") == "read"
@@ -197,6 +207,35 @@ class TestRequiredScope:
"""Only POST is elevated — GET falls through to read."""
assert required_scope("GET", "/api/_internal/mcp-reload") == "read"
# Workstream sub-resource mutations (parametric paths)
def test_ws_delete_needs_write(self):
assert required_scope("POST", "/api/workstreams/abc123/delete") == "write"
def test_ws_open_needs_write(self):
assert required_scope("POST", "/api/workstreams/abc123/open") == "write"
def test_ws_refresh_title_needs_write(self):
assert required_scope("POST", "/api/workstreams/abc123/refresh-title") == "write"
def test_ws_title_needs_write(self):
assert required_scope("POST", "/api/workstreams/abc123/title") == "write"
def test_v1_ws_delete_needs_write(self):
assert required_scope("POST", "/v1/api/workstreams/abc123/delete") == "write"
def test_proxy_ws_delete_needs_write(self):
assert required_scope("POST", "/node/n1/v1/api/workstreams/abc123/delete") == "write"
def test_proxy_ws_open_needs_write(self):
assert required_scope("POST", "/node/n1/v1/api/workstreams/abc123/open") == "write"
def test_proxy_ws_title_needs_write(self):
assert required_scope("POST", "/node/n1/v1/api/workstreams/abc123/title") == "write"
def test_ws_get_is_still_read(self):
"""GET on workstream sub-resource is not elevated."""
assert required_scope("GET", "/api/workstreams/abc123/delete") == "read"
# ---------------------------------------------------------------------------
# TestExtractBearer
@@ -372,7 +411,7 @@ class TestCheckRequest:
def test_write_read_token_403(self, read_jwt):
allowed, status, msg, _result = check_request(
"POST", "/api/send", read_jwt, jwt_secret=self._SECRET
"POST", "/api/workstreams/abc/send", read_jwt, jwt_secret=self._SECRET
)
assert allowed is False
assert status == 403
@@ -380,14 +419,14 @@ class TestCheckRequest:
def test_write_full_token_ok(self, full_jwt):
allowed, status, msg, _result = check_request(
"POST", "/api/send", full_jwt, jwt_secret=self._SECRET
"POST", "/api/workstreams/abc/send", full_jwt, jwt_secret=self._SECRET
)
assert allowed is True
assert status == 200
def test_approve_read_token_403(self, read_jwt):
allowed, status, msg, _result = check_request(
"POST", "/api/approve", read_jwt, jwt_secret=self._SECRET
"POST", "/api/workstreams/abc/approve", read_jwt, jwt_secret=self._SECRET
)
assert allowed is False
assert status == 403
@@ -395,7 +434,10 @@ class TestCheckRequest:
def test_proxy_write_read_token_403(self, read_jwt):
"""Read tokens cannot escalate to write ops via proxy routes."""
allowed, status, msg, _result = check_request(
"POST", "/node/node-a/api/send", read_jwt, jwt_secret=self._SECRET
"POST",
"/node/node-a/api/workstreams/abc/send",
read_jwt,
jwt_secret=self._SECRET,
)
assert allowed is False
assert status == 403
@@ -403,7 +445,10 @@ class TestCheckRequest:
def test_proxy_write_trailing_slash_read_token_403(self, read_jwt):
"""Trailing slash must not bypass write-role check on proxy routes."""
allowed, status, msg, _result = check_request(
"POST", "/node/node-a/api/send/", read_jwt, jwt_secret=self._SECRET
"POST",
"/node/node-a/api/workstreams/abc/send/",
read_jwt,
jwt_secret=self._SECRET,
)
assert allowed is False
assert status == 403
@@ -411,7 +456,7 @@ class TestCheckRequest:
def test_direct_write_trailing_slash_read_token_403(self, read_jwt):
"""Trailing slash must not bypass write-role check on direct routes."""
allowed, status, msg, _result = check_request(
"POST", "/api/send/", read_jwt, jwt_secret=self._SECRET
"POST", "/api/workstreams/abc/send/", read_jwt, jwt_secret=self._SECRET
)
assert allowed is False
assert status == 403
@@ -419,14 +464,20 @@ class TestCheckRequest:
def test_proxy_write_full_token_ok(self, full_jwt):
"""Full tokens pass through proxy write routes."""
allowed, status, msg, _result = check_request(
"POST", "/node/node-a/api/send", full_jwt, jwt_secret=self._SECRET
"POST",
"/node/node-a/api/workstreams/abc/send",
full_jwt,
jwt_secret=self._SECRET,
)
assert allowed is True
def test_proxy_v1_write_read_token_403(self, read_jwt):
"""Read tokens cannot escalate to write ops via v1 proxy routes."""
allowed, status, msg, _result = check_request(
"POST", "/node/node-a/v1/api/send", read_jwt, jwt_secret=self._SECRET
"POST",
"/node/node-a/v1/api/workstreams/abc/send",
read_jwt,
jwt_secret=self._SECRET,
)
assert allowed is False
assert status == 403
@@ -434,7 +485,10 @@ class TestCheckRequest:
def test_proxy_v1_write_full_token_ok(self, full_jwt):
"""Full tokens pass through v1 proxy write routes."""
allowed, status, msg, _result = check_request(
"POST", "/node/node-a/v1/api/send", full_jwt, jwt_secret=self._SECRET
"POST",
"/node/node-a/v1/api/workstreams/abc/send",
full_jwt,
jwt_secret=self._SECRET,
)
assert allowed is True
@@ -466,7 +520,7 @@ class TestCheckRequest:
def test_approve_full_token_ok(self, full_jwt):
allowed, status, msg, _result = check_request(
"POST", "/api/approve", full_jwt, jwt_secret=self._SECRET
"POST", "/api/workstreams/abc/approve", full_jwt, jwt_secret=self._SECRET
)
assert allowed is True
@@ -508,7 +562,7 @@ class TestCheckRequestWithCookie:
def test_bearer_takes_precedence_over_cookie(self, read_jwt, full_jwt):
allowed, status, _, _r = check_request(
"POST",
"/api/send",
"/api/workstreams/abc/send",
f"Bearer {full_jwt}",
cookie_header=f"turnstone_auth={read_jwt}",
jwt_secret=self._SECRET,
@@ -529,7 +583,7 @@ class TestCheckRequestWithCookie:
def test_cookie_read_on_write_403(self, read_jwt):
allowed, status, _, _r = check_request(
"POST",
"/api/send",
"/api/workstreams/abc/send",
None,
cookie_header=f"turnstone_auth={read_jwt}",
jwt_secret=self._SECRET,
@@ -540,7 +594,7 @@ class TestCheckRequestWithCookie:
def test_cookie_full_on_write_ok(self, full_jwt):
allowed, status, _, _r = check_request(
"POST",
"/api/send",
"/api/workstreams/abc/send",
None,
cookie_header=f"turnstone_auth={full_jwt}",
jwt_secret=self._SECRET,
@@ -605,9 +659,15 @@ class TestServerAuth:
mock_ws.name = "test"
mock_ws.state = WorkstreamState.IDLE
mock_ws.session = mock_session
# Set kind / parent_ws_id / user_id explicitly so list_workstreams
# JSON-serializes them — a bare MagicMock attribute returns another
# MagicMock that fails json.dumps and surfaces as 500.
mock_ws.kind = "interactive"
mock_ws.parent_ws_id = None
mock_ws.user_id = "u1"
mock_mgr = MagicMock()
mock_mgr.list_all.return_value = [mock_ws]
mock_mgr.max_workstreams = 10
mock_mgr.max_active = 10
from turnstone.core.auth import JWT_AUD_SERVER
@@ -664,25 +724,25 @@ class TestServerAuth:
def test_api_send_read_token_403(self):
resp = self.client.post(
"/v1/api/send",
"/v1/api/workstreams/x/send",
headers=self._read_hdr,
json={"message": "hello", "ws_id": "x"},
json={"message": "hello"},
)
assert resp.status_code == 403
assert "Forbidden" in resp.json().get("error", "")
def test_api_send_full_token_passes_auth(self):
resp = self.client.post(
"/v1/api/send",
"/v1/api/workstreams/nonexistent/send",
headers=self._full_hdr,
json={"message": "hello", "ws_id": "nonexistent"},
json={"message": "hello"},
)
assert resp.status_code not in (401, 403)
def test_api_send_no_token_401(self):
resp = self.client.post(
"/v1/api/send",
json={"message": "hello", "ws_id": "x"},
"/v1/api/workstreams/x/send",
json={"message": "hello"},
)
assert resp.status_code == 401
@@ -695,7 +755,7 @@ class TestServerAuth:
def test_options_no_auth_required(self):
resp = self.client.options(
"/v1/api/send",
"/v1/api/workstreams/x/send",
headers={
"Origin": "http://example.com",
"Access-Control-Request-Method": "POST",
@@ -821,9 +881,15 @@ class TestServerLogin:
mock_ws.name = "test"
mock_ws.state = WorkstreamState.IDLE
mock_ws.session = mock_session
# Set kind / parent_ws_id / user_id explicitly so list_workstreams
# JSON-serializes them — a bare MagicMock attribute returns another
# MagicMock that fails json.dumps and surfaces as 500.
mock_ws.kind = "interactive"
mock_ws.parent_ws_id = None
mock_ws.user_id = "u1"
mock_mgr = MagicMock()
mock_mgr.list_all.return_value = [mock_ws]
mock_mgr.max_workstreams = 10
mock_mgr.max_active = 10
# Mock storage with a test user for password login
from turnstone.core.auth import hash_password
@@ -911,6 +977,163 @@ class TestServerLogin:
resp = self.test_client.get("/v1/api/workstreams")
assert resp.status_code == 401
def test_whoami_includes_exp(self):
"""whoami exposes the JWT exp so the frontend can schedule refresh."""
import time
self.test_client.post(
"/v1/api/auth/login",
json={"username": "testuser", "password": "testpass"},
)
resp = self.test_client.get("/v1/api/auth/whoami")
assert resp.status_code == 200
data = resp.json()
assert "exp" in data
# Default JWT TTL is 24h; exp should be > now and < now + 25h.
now = int(time.time())
assert now < data["exp"] < now + 25 * 3600
def test_refresh_returns_new_jwt_and_cookie(self):
"""POST /api/auth/refresh re-mints the cookie with a fresh exp."""
from turnstone.core.auth import AUTH_COOKIE
# Storage needs get_user_permissions for the refresh re-resolve path.
# Mock is shared across tests in the class — re-arm here in case a
# prior test left it default.
self.test_client.app.state.auth_storage.get_user_permissions.return_value = {
"read",
"write",
"approve",
}
login = self.test_client.post(
"/v1/api/auth/login",
json={"username": "testuser", "password": "testpass"},
)
assert login.status_code == 200
refresh = self.test_client.post("/v1/api/auth/refresh")
assert refresh.status_code == 200
body = refresh.json()
assert body["status"] == "ok"
assert body["user_id"] == "uid_test"
assert "jwt" in body
# Set-Cookie header must be present so the browser updates. Don't
# assert the new JWT differs from the original — sub-second login
# and refresh produce identical iat/exp claims and therefore an
# identical token, which is fine: the cookie still gets re-set.
cookie_hdr = refresh.headers.get("set-cookie", "")
assert AUTH_COOKIE in cookie_hdr
assert "HttpOnly" in cookie_hdr
# The refreshed cookie must keep working.
resp = self.test_client.get("/v1/api/workstreams")
assert resp.status_code == 200
def test_refresh_response_includes_exp_and_permissions(self):
"""Refresh response shape must match whoami so the frontend can
populate sessionStorage + reschedule the next refresh off the
single round-trip without a follow-up /whoami call."""
import time
self.test_client.app.state.auth_storage.get_user_permissions.return_value = {
"read",
"write",
"approve",
}
login = self.test_client.post(
"/v1/api/auth/login",
json={"username": "testuser", "password": "testpass"},
)
assert login.status_code == 200
refresh = self.test_client.post("/v1/api/auth/refresh")
assert refresh.status_code == 200
body = refresh.json()
# exp present + within the expected default JWT TTL window
assert "exp" in body, body
now = int(time.time())
assert now < body["exp"] < now + 25 * 3600, body
# permissions present + non-empty (matches the seeded role set)
assert body.get("permissions"), body
assert "write" in body["permissions"].split(",")
def test_refresh_unauthenticated_401(self):
"""Refresh requires a currently-valid cookie — no cookie → 401."""
# Clear cookies on the test client
self.test_client.cookies.clear()
resp = self.test_client.post("/v1/api/auth/refresh")
assert resp.status_code == 401
def test_refresh_storage_failure_falls_back(self):
"""Transient storage error → fall back to in-token claims, not 403.
The earlier implementation called _load_user_permissions() which
swallows exceptions and returns set(); that path was
indistinguishable from a deleted user (legitimate 403). The
handler now calls storage.get_user_permissions() directly so
DB hiccups fall through to in-token perms.
"""
# Re-arm the storage so login works first
self.test_client.app.state.auth_storage.get_user_permissions.return_value = {
"read",
"write",
"approve",
}
login = self.test_client.post(
"/v1/api/auth/login",
json={"username": "testuser", "password": "testpass"},
)
assert login.status_code == 200
# Now make storage raise on the refresh re-resolve
self.test_client.app.state.auth_storage.get_user_permissions.side_effect = RuntimeError(
"db down"
)
try:
resp = self.test_client.post("/v1/api/auth/refresh")
assert resp.status_code == 200, resp.text
body = resp.json()
# Permissions should still be present (fell back to in-token claims)
assert body.get("permissions"), body
finally:
# Restore for any subsequent tests
self.test_client.app.state.auth_storage.get_user_permissions.side_effect = None
self.test_client.app.state.auth_storage.get_user_permissions.return_value = {
"read",
"write",
"approve",
}
def test_refresh_user_with_no_perms_403(self):
"""Storage returns empty (user deleted/role-stripped) → 403.
Distinguished from the storage-failure case above because
get_user_permissions returned a value (the empty set) without
raising that's an authoritative "no roles", not a hiccup.
"""
self.test_client.app.state.auth_storage.get_user_permissions.return_value = {
"read",
"write",
"approve",
}
login = self.test_client.post(
"/v1/api/auth/login",
json={"username": "testuser", "password": "testpass"},
)
assert login.status_code == 200
self.test_client.app.state.auth_storage.get_user_permissions.return_value = set()
try:
resp = self.test_client.post("/v1/api/auth/refresh")
assert resp.status_code == 403
finally:
self.test_client.app.state.auth_storage.get_user_permissions.return_value = {
"read",
"write",
"approve",
}
class TestConsoleLogin:
"""Test login/logout cookie flow on turnstone-console."""
@@ -1098,6 +1321,56 @@ class TestJWTAudienceIssuer:
result = validate_jwt(token, self.SECRET, audience="")
assert result is not None
def test_validate_jwt_accepts_within_leeway_after_expiry(self):
"""validate_jwt has 30s leeway for clock skew across hosts/processes."""
import time
import jwt as pyjwt
from turnstone.core.auth import JWT_ISSUER, validate_jwt
# Mint a token that "expired" 10 seconds ago — still within 30s leeway.
now = int(time.time())
token = pyjwt.encode(
{
"sub": "user1",
"scopes": "read",
"src": "test",
"iss": JWT_ISSUER,
"iat": now - 100,
"exp": now - 10,
},
self.SECRET,
algorithm="HS256",
)
result = validate_jwt(token, self.SECRET, audience="")
assert result is not None
assert result.user_id == "user1"
def test_validate_jwt_rejects_past_leeway(self):
"""Tokens expired beyond the 30s leeway must still be rejected."""
import time
import jwt as pyjwt
from turnstone.core.auth import JWT_ISSUER, validate_jwt
now = int(time.time())
token = pyjwt.encode(
{
"sub": "user1",
"scopes": "read",
"src": "test",
"iss": JWT_ISSUER,
"iat": now - 200,
"exp": now - 60,
},
self.SECRET,
algorithm="HS256",
)
result = validate_jwt(token, self.SECRET, audience="")
assert result is None
def test_create_jwt_expiry_seconds(self):
import jwt as pyjwt
@@ -1145,6 +1418,132 @@ class TestJWTAudienceIssuer:
create_jwt("user1", frozenset({"read"}), "test", self.SECRET, expiry_seconds=-1)
class TestJWTVersionClaim:
SECRET = "test-secret-that-is-at-least-32-chars"
def test_create_jwt_with_version(self):
import jwt as pyjwt
from turnstone.core.auth import create_jwt
token = create_jwt("user1", frozenset({"read"}), "test", self.SECRET, version="1.2")
payload = pyjwt.decode(
token, self.SECRET, algorithms=["HS256"], options={"verify_aud": False}
)
assert payload["ver"] == "1.2"
def test_create_jwt_without_version(self):
import jwt as pyjwt
from turnstone.core.auth import create_jwt
token = create_jwt("user1", frozenset({"read"}), "test", self.SECRET)
payload = pyjwt.decode(
token, self.SECRET, algorithms=["HS256"], options={"verify_aud": False}
)
assert "ver" not in payload
def test_validate_jwt_carries_token_version(self):
from turnstone.core.auth import create_jwt, validate_jwt
token = create_jwt("user1", frozenset({"read"}), "test", self.SECRET, version="1.2")
result = validate_jwt(token, self.SECRET)
assert result is not None
assert result.user_id == "user1"
assert result.token_version == "1.2"
def test_validate_jwt_no_ver_returns_empty_token_version(self):
from turnstone.core.auth import create_jwt, validate_jwt
token = create_jwt("user1", frozenset({"read"}), "test", self.SECRET)
result = validate_jwt(token, self.SECRET)
assert result is not None
assert result.token_version == ""
def test_check_request_accepts_matching_version(self):
from turnstone.core.auth import JWT_AUD_SERVER, check_request, create_jwt
token = create_jwt(
"user1",
frozenset({"read"}),
"test",
self.SECRET,
audience=JWT_AUD_SERVER,
version="1.2",
)
allowed, _status, _msg, result = check_request(
"GET",
"/v1/api/workstreams",
f"Bearer {token}",
jwt_secret=self.SECRET,
jwt_audience=JWT_AUD_SERVER,
jwt_version="1.2",
)
assert allowed
assert result is not None
def test_check_request_accepts_no_ver_backward_compat(self):
from turnstone.core.auth import JWT_AUD_SERVER, check_request, create_jwt
# Token without ver claim should be accepted (backward compat)
token = create_jwt(
"user1",
frozenset({"read"}),
"test",
self.SECRET,
audience=JWT_AUD_SERVER,
)
allowed, _status, _msg, _result = check_request(
"GET",
"/v1/api/workstreams",
f"Bearer {token}",
jwt_secret=self.SECRET,
jwt_audience=JWT_AUD_SERVER,
jwt_version="1.2",
)
assert allowed
def test_check_request_rejects_old_version_jwt(self):
from turnstone.core.auth import JWT_AUD_SERVER, check_request, create_jwt
token = create_jwt(
"user1",
frozenset({"read"}),
"test",
self.SECRET,
audience=JWT_AUD_SERVER,
version="1.1",
)
allowed, status, msg, _result = check_request(
"GET",
"/v1/api/workstreams",
f"Bearer {token}",
jwt_secret=self.SECRET,
jwt_audience=JWT_AUD_SERVER,
jwt_version="1.2",
)
assert not allowed
assert status == 401
assert msg == "version_mismatch"
class TestVersionSlot:
def test_returns_major_minor(self):
from turnstone.core.auth import jwt_version_slot
slot = jwt_version_slot()
parts = slot.split(".")
assert len(parts) == 2
def test_strips_patch_and_prerelease(self):
from unittest.mock import patch
with patch("turnstone.__version__", "2.3.1a5"):
from turnstone.core.auth import jwt_version_slot
assert jwt_version_slot() == "2.3"
class TestServiceTokenManager:
SECRET = "test-secret-that-is-at-least-32-chars"
@@ -1224,6 +1623,22 @@ class TestServiceTokenManager:
)
assert payload["aud"] == JWT_AUD_SERVER
def test_service_token_no_version_claim(self):
import jwt as pyjwt
from turnstone.core.auth import ServiceTokenManager
mgr = ServiceTokenManager(
user_id="svc",
scopes=frozenset({"read"}),
source="test",
secret=self.SECRET,
)
payload = pyjwt.decode(
mgr.token, self.SECRET, algorithms=["HS256"], options={"verify_aud": False}
)
assert "ver" not in payload
class TestIsSecureRequest:
def test_https_scheme(self):
@@ -1249,13 +1664,11 @@ class TestIsSecureRequest:
class TestSecretStrength:
def test_short_secret_exits(self):
import turnstone.core.auth as auth_mod
old = os.environ.get("TURNSTONE_JWT_SECRET", "")
os.environ["TURNSTONE_JWT_SECRET"] = "short"
try:
with pytest.raises(SystemExit):
auth_mod.load_jwt_secret()
load_jwt_secret()
finally:
if old:
os.environ["TURNSTONE_JWT_SECRET"] = old
@@ -1263,14 +1676,12 @@ class TestSecretStrength:
os.environ.pop("TURNSTONE_JWT_SECRET", None)
def test_missing_secret_exits(self):
import turnstone.core.auth as auth_mod
with (
patch("turnstone.core.config.load_config", return_value={}),
patch.dict(os.environ, {}, clear=True),
pytest.raises(SystemExit),
):
auth_mod.load_jwt_secret()
load_jwt_secret()
class TestCorsConfigurable:
@@ -1284,7 +1695,7 @@ class TestCorsConfigurable:
mgr = MagicMock()
mgr.list_all.return_value = []
mgr.max_workstreams = 10
mgr.max_active = 10
app = srv_mod.create_app(
workstreams=mgr,
global_queue=queue.Queue(),
@@ -1305,7 +1716,7 @@ class TestCorsConfigurable:
mgr = MagicMock()
mgr.list_all.return_value = []
mgr.max_workstreams = 10
mgr.max_active = 10
app = srv_mod.create_app(
workstreams=mgr,
global_queue=queue.Queue(),
+11 -11
View File
@@ -175,10 +175,10 @@ class TestRequiredScope:
assert required_scope("GET", "/api/workstreams") == "read"
def test_post_write(self):
assert required_scope("POST", "/api/send") == "write"
assert required_scope("POST", "/api/workstreams/abc/send") == "write"
def test_post_approve(self):
assert required_scope("POST", "/api/approve") == "approve"
assert required_scope("POST", "/api/workstreams/abc/approve") == "approve"
def test_admin_prefix(self):
assert required_scope("GET", "/api/admin/users") == "approve"
@@ -186,14 +186,14 @@ class TestRequiredScope:
assert required_scope("DELETE", "/api/admin/users/abc") == "approve"
def test_versioned_path(self):
assert required_scope("POST", "/v1/api/send") == "write"
assert required_scope("POST", "/v1/api/approve") == "approve"
assert required_scope("POST", "/v1/api/workstreams/abc/send") == "write"
assert required_scope("POST", "/v1/api/workstreams/abc/approve") == "approve"
def test_proxy_write(self):
assert required_scope("POST", "/node/n1/api/send") == "write"
assert required_scope("POST", "/node/n1/api/workstreams/abc/send") == "write"
def test_proxy_approve(self):
assert required_scope("POST", "/node/n1/api/approve") == "approve"
assert required_scope("POST", "/node/n1/api/workstreams/abc/approve") == "approve"
# ---------------------------------------------------------------------------
@@ -270,7 +270,7 @@ class TestCheckRequestScopes:
jwt_tok = create_jwt("u1", frozenset({"read"}), "test", self._SECRET)
allowed, status, msg, _ = check_request(
"POST",
"/api/send",
"/api/workstreams/abc/send",
f"Bearer {jwt_tok}",
jwt_secret=self._SECRET,
)
@@ -282,7 +282,7 @@ class TestCheckRequestScopes:
jwt_tok = create_jwt("u1", frozenset({"read"}), "test", self._SECRET)
allowed, status, msg, _ = check_request(
"POST",
"/api/approve",
"/api/workstreams/abc/approve",
f"Bearer {jwt_tok}",
jwt_secret=self._SECRET,
)
@@ -294,7 +294,7 @@ class TestCheckRequestScopes:
jwt_tok = create_jwt("u1", frozenset({"read", "write", "approve"}), "test", self._SECRET)
allowed, status, msg, result = check_request(
"POST",
"/api/approve",
"/api/workstreams/abc/approve",
f"Bearer {jwt_tok}",
jwt_secret=self._SECRET,
)
@@ -306,7 +306,7 @@ class TestCheckRequestScopes:
jwt_tok = create_jwt("u1", frozenset({"read", "write"}), "db", self._SECRET)
allowed, status, msg, result = check_request(
"POST",
"/api/send",
"/api/workstreams/abc/send",
f"Bearer {jwt_tok}",
jwt_secret=self._SECRET,
)
@@ -318,7 +318,7 @@ class TestCheckRequestScopes:
jwt_tok = create_jwt("u1", frozenset({"read"}), "db", self._SECRET)
allowed, status, msg, _ = check_request(
"POST",
"/api/send",
"/api/workstreams/abc/send",
f"Bearer {jwt_tok}",
jwt_secret=self._SECRET,
)
+48 -1
View File
@@ -19,6 +19,7 @@ from turnstone.bootstrap import (
_tool_generate_secret,
_tool_read_file,
_tool_validate_api_key,
_tool_write_compose,
_tool_write_file,
execute_tool,
)
@@ -103,6 +104,52 @@ class TestWriteFile:
assert (tmp_path / "changed.txt").read_text() == "new\n"
class TestWriteCompose:
def test_writes_compose_file(self, tmp_path: Path) -> None:
with patch("builtins.input", return_value="y"):
result = _tool_write_compose(tmp_path, {})
assert "written successfully" in result
assert "ghcr.io" in result
content = (tmp_path / "compose.yaml").read_text()
assert "ghcr.io/turnstonelabs/turnstone" in content
assert "TURNSTONE_IMAGE_TAG" in content
def test_user_declines(self, tmp_path: Path) -> None:
with patch("builtins.input", return_value="n"):
result = _tool_write_compose(tmp_path, {})
assert "declined" in result
assert not (tmp_path / "compose.yaml").exists()
def test_identical_content_skipped(self, tmp_path: Path) -> None:
# Write it once
with patch("builtins.input", return_value="y"):
_tool_write_compose(tmp_path, {})
# Second call should skip
result = _tool_write_compose(tmp_path, {})
assert "already exists" in result
def test_no_build_blocks(self, tmp_path: Path) -> None:
with patch("builtins.input", return_value="y"):
_tool_write_compose(tmp_path, {})
content = (tmp_path / "compose.yaml").read_text()
assert "build:" not in content
assert "dockerfile:" not in content.lower()
def test_overwrites_different_content(self, tmp_path: Path) -> None:
(tmp_path / "compose.yaml").write_text("old content\n")
with patch("builtins.input", return_value="y"):
result = _tool_write_compose(tmp_path, {})
assert "written successfully" in result
content = (tmp_path / "compose.yaml").read_text()
assert "ghcr.io" in content
def test_no_local_image_references(self, tmp_path: Path) -> None:
with patch("builtins.input", return_value="y"):
_tool_write_compose(tmp_path, {})
content = (tmp_path / "compose.yaml").read_text()
assert "turnstone:local" not in content
class TestGenerateSecret:
def test_default_length(self) -> None:
secret = _tool_generate_secret({})
@@ -620,7 +667,7 @@ class TestConstants:
assert func["parameters"]["type"] == "object"
def test_tool_count(self) -> None:
assert len(TOOLS) == 7
assert len(TOOLS) == 8
def test_all_tools_have_implementations(self) -> None:
from turnstone.bootstrap import TOOL_FUNCTIONS
+15 -4
View File
@@ -168,10 +168,18 @@ class TestCancelDuringStreaming:
assert ui.states[-1] == "idle"
# Check that "[Generation cancelled]" was emitted
assert any("cancelled" in i.lower() for i in ui.infos)
# The partial content should be preserved as an assistant message
# The partial content should be preserved as an assistant
# message AND annotated with a marker that downstream readers
# (inspect_workstream, the next coord turn) can use to
# distinguish a cancelled fragment from a completed turn — the
# raw "Hello world" without a marker would look like the
# final assistant answer to a coord LLM reading the child's
# transcript.
assistant_msgs = [m for m in session.messages if m["role"] == "assistant"]
assert len(assistant_msgs) == 1
assert assistant_msgs[0]["content"] == "Hello world"
content = assistant_msgs[0]["content"]
assert content.startswith("Hello world")
assert "[generation cancelled before completion]" in content
# No tool_calls in the partial message
assert "tool_calls" not in assistant_msgs[0]
@@ -511,10 +519,13 @@ class TestStreamAbort:
# Should complete as cancelled, not error
assert "idle" in ui.states
assert any("cancelled" in i.lower() for i in ui.infos)
# Partial content preserved
# Partial content preserved AND annotated with the
# cancelled-before-completion marker.
assistant_msgs = [m for m in session.messages if m["role"] == "assistant"]
assert len(assistant_msgs) == 1
assert assistant_msgs[0]["content"] == "Hello"
content = assistant_msgs[0]["content"]
assert content.startswith("Hello")
assert "[generation cancelled before completion]" in content
def test_non_cancel_exception_not_swallowed(self, tmp_db):
"""Exceptions during streaming that aren't caused by cancel
+649 -37
View File
@@ -8,6 +8,13 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
# discord.utils.escape_markdown passes 'count' as positional to re.sub,
# which is deprecated in Python 3.13+. This is a discord.py bug (fixed
# in newer releases); suppress here to keep the test output clean.
pytestmark = pytest.mark.filterwarnings(
"ignore:.*'count' is passed as positional argument:DeprecationWarning"
)
discord = pytest.importorskip("discord")
@@ -21,6 +28,21 @@ def _run(coro):
return asyncio.run(coro)
def _bind_ws_event_handlers(bot, cls):
"""Bind ``_on_ws_event`` + every ``_handle_*`` method from *cls* to *bot*.
``MagicMock(spec=cls)`` stubs async methods as ``AsyncMock`` no-ops,
so dispatcher tests that invoke the real ``_on_ws_event`` must also
bind the per-event handlers it delegates to.
"""
bot._on_ws_event = cls._on_ws_event.__get__(bot, cls)
for name in dir(cls):
if name.startswith("_handle_"):
attr = getattr(cls, name)
if callable(attr):
setattr(bot, name, attr.__get__(bot, cls))
def _make_message(*, bot=False, guild=True, content="hello", channel=None, reference=None):
"""Build a mock ``discord.Message``."""
msg = MagicMock(spec=discord.Message)
@@ -121,7 +143,7 @@ class TestStreamingMessage:
_run(sm.append("hello "))
_run(sm.append("world"))
assert "".join(sm._buffer) == "hello world"
assert sm.accumulated_text == "hello world"
def test_finalize_sends_when_no_prior_message(self):
from turnstone.channels.discord.bot import StreamingMessage
@@ -146,7 +168,7 @@ class TestStreamingMessage:
# First append triggers flush (interval=0) which creates the message.
_run(sm.append("hi"))
assert sm._message is sent_msg
assert sm.message is sent_msg
_run(sm.append(" there"))
_run(sm.finalize())
@@ -253,6 +275,90 @@ class TestMessageCog:
ts.router.send_message.assert_not_awaited()
# ---------------------------------------------------------------------------
# /ask command — model selection
# ---------------------------------------------------------------------------
class TestAskModelSelection:
"""Tests for the /ask command's model parameter and channel default."""
def _make_cog_and_interaction(self):
from turnstone.channels.discord.cog import MessageCog
bot = MagicMock()
bot.user = MagicMock()
bot.user.id = 99999
ts = MagicMock()
ts.router = MagicMock()
ts.router.resolve_user = AsyncMock(return_value="u_abc")
ts.router.get_or_create_workstream = AsyncMock(return_value=("ws-1", True))
ts.router.send_message = AsyncMock()
ts.router.get_channel_default_alias = AsyncMock(return_value="")
ts.subscribe_ws = AsyncMock()
ts.config = MagicMock()
ts.config.model = "cli-model"
ts.config.thread_auto_archive = 1440
bot.turnstone = ts
cog = MessageCog(bot)
interaction = MagicMock(spec=discord.Interaction)
interaction.user = MagicMock()
interaction.user.id = 67890
interaction.response = MagicMock()
interaction.response.defer = AsyncMock()
interaction.followup = MagicMock()
interaction.followup.send = AsyncMock()
thread = AsyncMock(spec=discord.Thread)
thread.id = 111
thread.mention = "<#111>"
channel = MagicMock(spec=discord.TextChannel)
channel.create_thread = AsyncMock(return_value=thread)
interaction.channel = channel
return cog, ts, interaction
def test_explicit_model_overrides_all(self):
cog, ts, interaction = self._make_cog_and_interaction()
ts.router.get_channel_default_alias = AsyncMock(return_value="channel-default")
_run(cog._cmd_ask(interaction, "hello", model="explicit-model"))
_, kwargs = ts.router.get_or_create_workstream.call_args
assert kwargs["model"] == "explicit-model"
def test_channel_default_used_when_no_explicit_model(self):
cog, ts, interaction = self._make_cog_and_interaction()
ts.router.get_channel_default_alias = AsyncMock(return_value="channel-default")
_run(cog._cmd_ask(interaction, "hello"))
_, kwargs = ts.router.get_or_create_workstream.call_args
assert kwargs["model"] == "channel-default"
def test_cli_model_fallback(self):
cog, ts, interaction = self._make_cog_and_interaction()
# Channel default is empty → fall back to CLI --model.
ts.router.get_channel_default_alias = AsyncMock(return_value="")
_run(cog._cmd_ask(interaction, "hello"))
_, kwargs = ts.router.get_or_create_workstream.call_args
assert kwargs["model"] == "cli-model"
def test_empty_model_when_no_defaults(self):
cog, ts, interaction = self._make_cog_and_interaction()
ts.router.get_channel_default_alias = AsyncMock(return_value="")
ts.config.model = ""
_run(cog._cmd_ask(interaction, "hello"))
_, kwargs = ts.router.get_or_create_workstream.call_args
assert kwargs["model"] == ""
# ---------------------------------------------------------------------------
# _parse_footer (views.py)
# ---------------------------------------------------------------------------
@@ -261,20 +367,20 @@ class TestMessageCog:
class TestParseFooter:
"""Tests for _parse_footer in views.py."""
def test_valid_footer(self):
def test_valid_footer_with_owner(self):
from turnstone.channels.discord.views import _parse_footer
interaction = _make_interaction(footer_text="ws_abc|corr_123|12345")
result = _parse_footer(interaction)
assert result == ("ws_abc", "corr_123", "12345")
def test_footer_without_owner_returns_empty_owner(self):
from turnstone.channels.discord.views import _parse_footer
# Legacy footer without an owner field (pre-upgrade posts).
interaction = _make_interaction(footer_text="ws_abc|corr_123")
result = _parse_footer(interaction)
assert result == ("ws_abc", "corr_123")
def test_footer_with_pipe_in_correlation(self):
from turnstone.channels.discord.views import _parse_footer
interaction = _make_interaction(footer_text="ws_abc|corr|extra")
result = _parse_footer(interaction)
# split("|", 1) means the second part includes everything after first pipe.
assert result == ("ws_abc", "corr|extra")
assert result == ("ws_abc", "corr_123", "")
def test_no_message_returns_none(self):
from turnstone.channels.discord.views import _parse_footer
@@ -337,7 +443,7 @@ class TestWsEventFinalization:
bot._notify_reply_channels = {}
# Use the real _on_ws_event method
bot._on_ws_event = TurnstoneBot._on_ws_event.__get__(bot, TurnstoneBot)
_bind_ws_event_handlers(bot, TurnstoneBot)
thread = AsyncMock()
@@ -366,7 +472,7 @@ class TestWsEventFinalization:
bot._tool_info_msgs = {}
bot._pending_approval_msgs = {}
bot._notify_reply_channels = {}
bot._on_ws_event = TurnstoneBot._on_ws_event.__get__(bot, TurnstoneBot)
_bind_ws_event_handlers(bot, TurnstoneBot)
thread = AsyncMock()
@@ -387,6 +493,7 @@ class TestApprovalVerdictDisplay:
def _make_bot(self):
"""Build a mock TurnstoneBot with _on_ws_event bound."""
from turnstone.channels._routing import PolicyVerdict
from turnstone.channels.discord.bot import TurnstoneBot
bot = MagicMock(spec=TurnstoneBot)
@@ -402,7 +509,9 @@ class TestApprovalVerdictDisplay:
bot._pending_approval_msgs = {}
bot._notify_reply_channels = {}
bot._should_auto_approve = MagicMock(return_value=False)
bot._on_ws_event = TurnstoneBot._on_ws_event.__get__(bot, TurnstoneBot)
bot.router = MagicMock()
bot.router.evaluate_tool_policies = AsyncMock(return_value=PolicyVerdict(kind="none"))
_bind_ws_event_handlers(bot, TurnstoneBot)
return bot
def test_approval_with_heuristic_verdict(self):
@@ -521,7 +630,7 @@ class TestApprovalVerdictDisplay:
bot._tool_info_msgs = {}
bot._pending_approval_msgs = {"ws-1": MagicMock()}
bot._notify_reply_channels = {}
bot._on_ws_event = TurnstoneBot._on_ws_event.__get__(bot, TurnstoneBot)
_bind_ws_event_handlers(bot, TurnstoneBot)
thread = AsyncMock()
event = StreamEndEvent(ws_id="ws-1")
@@ -547,7 +656,7 @@ class TestStreamEndBehavior:
bot._tool_info_msgs = {}
bot._pending_approval_msgs = {}
bot._notify_reply_channels = {}
bot._on_ws_event = TurnstoneBot._on_ws_event.__get__(bot, TurnstoneBot)
_bind_ws_event_handlers(bot, TurnstoneBot)
return bot
def test_stream_end_no_streaming_no_send(self):
@@ -583,38 +692,88 @@ class TestStreamEndBehavior:
class TestNotificationTracking:
"""Tests for notification message tracking and DM reply routing."""
def test_send_notification_tracks_message(self):
"""send_notification should store message_id -> (ws_id, target_user) mapping."""
def _make_dm_bot(self, *, sent_message_id: int):
"""Build a MagicMock bot whose notification target resolves to a DM."""
from turnstone.channels.discord.bot import TurnstoneBot
bot = MagicMock(spec=TurnstoneBot)
bot.config = MagicMock()
bot.config.max_message_length = 2000
sent_msg = MagicMock()
sent_msg.id = sent_message_id
dm_channel = MagicMock()
dm_channel.send = AsyncMock(return_value=sent_msg)
user = MagicMock()
user.id = 7777
user.create_dm = AsyncMock(return_value=dm_channel)
inner_bot = MagicMock()
inner_bot.get_channel = MagicMock(return_value=None)
inner_bot.fetch_user = AsyncMock(return_value=user)
bot._bot = inner_bot
bot.send_notification = TurnstoneBot.send_notification.__get__(bot, TurnstoneBot)
bot._track_notification = TurnstoneBot._track_notification.__get__(bot, TurnstoneBot)
return bot
def test_send_notification_tracks_dm_with_user_id(self):
"""send_notification for a DM records (ws_id, resolved_user_id)."""
bot = self._make_dm_bot(sent_message_id=12345)
bot._notify_ws_map = {}
bot._MAX_NOTIFY_TRACKING = 100
bot.send = AsyncMock(return_value="12345")
_run(bot.send_notification("7777", "Hello", "ws-abc"))
# Tracked under the resolved Discord user ID, not the raw argument.
assert 12345 in bot._notify_ws_map
assert bot._notify_ws_map[12345] == ("ws-abc", "7777")
def test_send_notification_to_guild_channel_is_not_tracked(self):
"""Notifications delivered to a guild channel must not register reply tracking.
The reply-channel_id check treats the stored value as a Discord
user ID, so storing a channel ID would reject every legitimate
reply.
"""
from turnstone.channels.discord.bot import TurnstoneBot
bot = MagicMock(spec=TurnstoneBot)
bot.config = MagicMock()
bot.config.max_message_length = 2000
bot._notify_ws_map = {}
bot._MAX_NOTIFY_TRACKING = 100
sent_msg = MagicMock()
sent_msg.id = 99999
channel = MagicMock()
channel.send = AsyncMock(return_value=sent_msg)
inner_bot = MagicMock()
inner_bot.get_channel = MagicMock(return_value=channel)
bot._bot = inner_bot
bot.send_notification = TurnstoneBot.send_notification.__get__(bot, TurnstoneBot)
bot._track_notification = TurnstoneBot._track_notification.__get__(bot, TurnstoneBot)
_run(bot.send_notification("chan-1", "Hello", "ws-abc"))
_run(bot.send_notification("888888", "Hello", "ws-abc"))
assert 12345 in bot._notify_ws_map
assert bot._notify_ws_map[12345] == ("ws-abc", "chan-1")
assert bot._notify_ws_map == {}
def test_send_notification_evicts_old_entries(self):
"""Oldest notification tracking entries are evicted when cap is reached."""
from turnstone.channels.discord.bot import TurnstoneBot
bot = MagicMock(spec=TurnstoneBot)
bot = self._make_dm_bot(sent_message_id=4)
bot._MAX_NOTIFY_TRACKING = 3
bot._notify_ws_map = {
1: ("ws-1", "u1"),
2: ("ws-2", "u2"),
3: ("ws-3", "u3"),
}
bot.send = AsyncMock(return_value="4")
bot.send_notification = TurnstoneBot.send_notification.__get__(bot, TurnstoneBot)
bot._track_notification = TurnstoneBot._track_notification.__get__(bot, TurnstoneBot)
_run(bot.send_notification("chan-1", "Hello", "ws-4"))
_run(bot.send_notification("7777", "Hello", "ws-4"))
assert 4 in bot._notify_ws_map
assert 1 not in bot._notify_ws_map # oldest evicted
@@ -787,7 +946,7 @@ class TestNotificationTracking:
sent_msg.id = 88888
dm_channel.send = AsyncMock(return_value=sent_msg)
bot._notify_reply_channels = {"ws-1": (dm_channel, "u123")}
bot._on_ws_event = TurnstoneBot._on_ws_event.__get__(bot, TurnstoneBot)
_bind_ws_event_handlers(bot, TurnstoneBot)
bot._track_notification = TurnstoneBot._track_notification.__get__(bot, TurnstoneBot)
thread = AsyncMock()
@@ -822,7 +981,7 @@ class TestNotificationTracking:
dm_channel = AsyncMock()
bot._notify_reply_channels = {"ws-1": (dm_channel, "u123")}
bot._on_ws_event = TurnstoneBot._on_ws_event.__get__(bot, TurnstoneBot)
_bind_ws_event_handlers(bot, TurnstoneBot)
thread = AsyncMock()
@@ -886,6 +1045,235 @@ class TestFormatToolResult:
assert result.count("```") == 2
# ---------------------------------------------------------------------------
# Media embed detection and rendering
# ---------------------------------------------------------------------------
class TestTryParseMedia:
"""Tests for try_parse_media in _formatter.py."""
def test_stream_url_detected(self):
import json
from turnstone.channels._formatter import try_parse_media
data = json.dumps({"stream_url": "http://jf:8096/Videos/abc/stream", "container": "mp4"})
result = try_parse_media(data)
assert result is not None
assert result["stream_url"] == "http://jf:8096/Videos/abc/stream"
def test_media_details_detected(self):
import json
from turnstone.channels._formatter import try_parse_media
data = json.dumps({"id": "abc", "name": "Test Movie", "type": "Movie", "year": 2024})
result = try_parse_media(data)
assert result is not None
assert result["name"] == "Test Movie"
def test_search_results_detected(self):
import json
from turnstone.channels._formatter import try_parse_media
data = json.dumps({"results": [{"id": "1", "name": "Hit"}], "total_count": 1})
result = try_parse_media(data)
assert result is not None
assert len(result["results"]) == 1
def test_sessions_detected(self):
import json
from turnstone.channels._formatter import try_parse_media
data = json.dumps({"sessions": [{"id": "s1", "user_name": "ptrck"}]})
result = try_parse_media(data)
assert result is not None
def test_empty_results_returns_none(self):
import json
from turnstone.channels._formatter import try_parse_media
assert try_parse_media(json.dumps({"results": []})) is None
def test_plain_text_returns_none(self):
from turnstone.channels._formatter import try_parse_media
assert try_parse_media("just a string") is None
def test_non_dict_json_returns_none(self):
from turnstone.channels._formatter import try_parse_media
assert try_parse_media("[1, 2, 3]") is None
def test_unrelated_dict_returns_none(self):
import json
from turnstone.channels._formatter import try_parse_media
assert try_parse_media(json.dumps({"foo": "bar"})) is None
class TestIsSafeImageUrl:
"""Tests for _is_safe_image_url in _formatter.py."""
@staticmethod
def _patch_resolver(monkeypatch, ips):
"""Replace socket.getaddrinfo with a stub returning *ips*."""
import socket
def fake(host, port, family=0, *args, **kwargs): # noqa: ARG001
return [(family, 0, 0, "", (ip, 0)) for ip in ips]
monkeypatch.setattr(socket, "getaddrinfo", fake)
def test_http_url(self, monkeypatch):
from turnstone.channels._formatter import _is_safe_image_url
self._patch_resolver(monkeypatch, ["203.0.113.5"])
assert _run(_is_safe_image_url("http://jellyfin:8096/Items/abc/Images/Primary")) is True
def test_https_url(self, monkeypatch):
from turnstone.channels._formatter import _is_safe_image_url
self._patch_resolver(monkeypatch, ["203.0.113.5"])
assert (
_run(_is_safe_image_url("https://jellyfin.example.com/Items/abc/Images/Primary"))
is True
)
def test_ftp_rejected(self):
from turnstone.channels._formatter import _is_safe_image_url
assert _run(_is_safe_image_url("ftp://evil.com/image.jpg")) is False
def test_file_rejected(self):
from turnstone.channels._formatter import _is_safe_image_url
assert _run(_is_safe_image_url("file:///etc/passwd")) is False
def test_userinfo_rejected(self):
from turnstone.channels._formatter import _is_safe_image_url
assert _run(_is_safe_image_url("http://user:pass@jellyfin:8096/image")) is False
def test_empty_rejected(self):
from turnstone.channels._formatter import _is_safe_image_url
assert _run(_is_safe_image_url("")) is False
def test_private_ip_allowed(self):
from turnstone.channels._formatter import _is_safe_image_url
assert _run(_is_safe_image_url("http://192.168.0.6:8096/Items/abc/Images/Primary")) is True
def test_dns_rebinding_rejected(self, monkeypatch):
"""Hostname that resolves to a loopback IP must be rejected."""
from turnstone.channels._formatter import _is_safe_image_url
self._patch_resolver(monkeypatch, ["127.0.0.1"])
assert _run(_is_safe_image_url("http://rebind.example.com/image")) is False
def test_metadata_endpoint_rejected(self):
"""AWS/GCP metadata IP is link-local → rejected."""
from turnstone.channels._formatter import _is_safe_image_url
assert _run(_is_safe_image_url("http://169.254.169.254/latest/meta-data/")) is False
def test_ipv6_aws_nitro_metadata_rejected(self, monkeypatch):
"""fd00:ec2::254 is IPv6 ULA (is_private) but must be blocked —
the IPv4 169.254.169.254 check left this analogue open."""
from turnstone.channels._formatter import _is_safe_image_url
self._patch_resolver(monkeypatch, ["fd00:ec2::254"])
assert _run(_is_safe_image_url("http://nitro.example.com/")) is False
def test_ipv6_ecs_task_metadata_rejected(self, monkeypatch):
"""ECS Task Metadata lives in the same fd00:ec2::/32 prefix."""
from turnstone.channels._formatter import _is_safe_image_url
self._patch_resolver(monkeypatch, ["fd00:ec2::23"])
assert _run(_is_safe_image_url("http://ecs-meta.example.com/")) is False
class TestBuildMediaEmbed:
"""Tests for try_build_media_embed and embed builders."""
def test_single_item_embed_uses_web_url_not_stream_url(self):
import json
from turnstone.channels._formatter import try_parse_media
data = {
"name": "Test Movie",
"type": "Movie",
"year": 2024,
"stream_url": "http://jf:8096/Videos/abc/stream?api_key=SECRET",
"web_url": "http://jf:8096/web/#/details?id=abc",
"overview": "A test movie.",
}
parsed = try_parse_media(json.dumps(data))
assert parsed is not None
from turnstone.channels._formatter import _build_single_media_embed
embed = _build_single_media_embed(parsed, "mcp__mediamcp__get_stream_url")
# web_url should be the embed URL, never stream_url
assert embed.url == "http://jf:8096/web/#/details?id=abc"
assert "SECRET" not in str(embed.to_dict())
def test_search_results_embed_format(self):
import json
from turnstone.channels._formatter import try_parse_media
data = {
"results": [
{"name": "Movie A", "year": 2020, "type": "Movie", "runtime_minutes": 120},
{"name": "Movie B", "year": 2021, "type": "Movie"},
],
"total_count": 2,
}
parsed = try_parse_media(json.dumps(data))
from turnstone.channels._formatter import _build_search_results_embed
embed = _build_search_results_embed(parsed)
assert "Movie A" in embed.description
assert "Movie B" in embed.description
assert "2 of 2" in embed.footer.text
def test_build_media_embed_returns_none_for_plain_text(self):
from turnstone.channels._formatter import try_build_media_embed
http = MagicMock()
result = _run(try_build_media_embed("tool", "plain text", http=http))
assert result is None
def test_season_episode_string_values(self):
"""Season/episode numbers as strings should not raise."""
from turnstone.channels._formatter import _build_search_results_embed
data = {
"results": [
{
"name": "Pilot",
"type": "Episode",
"series_name": "Show",
"season_number": "1",
"episode_number": "1",
},
],
"total_count": 1,
}
embed = _build_search_results_embed(data)
assert "S01E01" in embed.description
# ---------------------------------------------------------------------------
# Thinking indicator lifecycle
# ---------------------------------------------------------------------------
@@ -910,7 +1298,7 @@ class TestThinkingIndicator:
bot._pending_approval_msgs = {}
bot._notify_reply_channels = {}
bot._should_auto_approve = MagicMock(return_value=False)
bot._on_ws_event = TurnstoneBot._on_ws_event.__get__(bot, TurnstoneBot)
_bind_ws_event_handlers(bot, TurnstoneBot)
return bot
def test_thinking_start_sends_message(self):
@@ -967,7 +1355,7 @@ class TestThinkingIndicator:
# Thinking message becomes the StreamingMessage base — no delete.
assert "ws-1" not in bot._thinking_msgs
sm = bot._streaming["ws-1"]
assert sm._message is thinking_msg
assert sm.message is thinking_msg
def test_stream_end_clears_thinking_message(self):
from turnstone.sdk.events import StreamEndEvent
@@ -1010,7 +1398,7 @@ class TestToolInfoEvent:
bot._pending_approval_msgs = {}
bot._notify_reply_channels = {}
bot._should_auto_approve = MagicMock(return_value=False)
bot._on_ws_event = TurnstoneBot._on_ws_event.__get__(bot, TurnstoneBot)
_bind_ws_event_handlers(bot, TurnstoneBot)
return bot
def test_sends_per_item_embed(self):
@@ -1103,8 +1491,9 @@ class TestToolResultEvent:
bot._tool_info_msgs = {}
bot._pending_approval_msgs = {}
bot._notify_reply_channels = {}
bot._http_client = MagicMock()
bot._should_auto_approve = MagicMock(return_value=False)
bot._on_ws_event = TurnstoneBot._on_ws_event.__get__(bot, TurnstoneBot)
_bind_ws_event_handlers(bot, TurnstoneBot)
return bot
def test_marks_info_done_and_sends_result(self):
@@ -1259,7 +1648,7 @@ class TestApprovalResolved:
bot._pending_approval_msgs = {}
bot._notify_reply_channels = {}
bot._should_auto_approve = MagicMock(return_value=False)
bot._on_ws_event = TurnstoneBot._on_ws_event.__get__(bot, TurnstoneBot)
_bind_ws_event_handlers(bot, TurnstoneBot)
return bot
def test_disables_buttons_on_timeout(self):
@@ -1327,3 +1716,226 @@ class TestChannelCLI:
main()
assert exc_info.value.code == 1
# ---------------------------------------------------------------------------
# Approval / plan-review interaction views — owner-check regression tests
# ---------------------------------------------------------------------------
def _make_view_interaction(user_id: int, footer: str | None) -> MagicMock:
"""Build a minimal interaction for ApprovalView / PlanReviewView tests."""
interaction = MagicMock(spec=discord.Interaction)
interaction.user = MagicMock()
interaction.user.id = user_id
interaction.response = MagicMock()
interaction.response.send_message = AsyncMock()
interaction.response.defer = AsyncMock()
interaction.response.send_modal = AsyncMock()
interaction.followup = MagicMock()
interaction.followup.send = AsyncMock()
interaction.message = MagicMock()
if footer is None:
interaction.message.embeds = []
else:
embed = MagicMock()
embed.footer.text = footer
interaction.message.embeds = [embed]
return interaction
def _make_view_bot() -> MagicMock:
"""Build a TurnstoneBot double with just the surface the views read."""
from turnstone.channels.discord.bot import TurnstoneBot
bot = MagicMock(spec=TurnstoneBot)
bot.router = MagicMock()
bot.router.resolve_user = AsyncMock(return_value="turnstone-user-1")
bot.router.send_approval = AsyncMock()
bot.router.send_plan_feedback = AsyncMock()
bot._pending_approval_msgs = {}
return bot
class TestApprovalViewOwnerCheck:
"""ApprovalView rejects clicks from anyone other than the session owner."""
def test_owner_approve_allowed(self, monkeypatch):
from turnstone.channels.discord.views import ApprovalView
# Avoid real disable_message_buttons (touches discord.ui internals).
monkeypatch.setattr(
"turnstone.channels.discord.views._disable_buttons",
AsyncMock(),
)
view = ApprovalView(_make_view_bot())
interaction = _make_view_interaction(user_id=42, footer="ws-1|corr-1|42")
_run(view._handle(interaction, approved=True, always=False))
view.bot.router.send_approval.assert_awaited_once_with(
ws_id="ws-1",
correlation_id="corr-1",
approved=True,
always=False,
)
def test_non_owner_rejected(self):
from turnstone.channels.discord.views import ApprovalView
view = ApprovalView(_make_view_bot())
interaction = _make_view_interaction(user_id=999, footer="ws-1|corr-1|42")
_run(view._handle(interaction, approved=True, always=False))
view.bot.router.send_approval.assert_not_awaited()
interaction.response.send_message.assert_awaited_once()
msg_kwargs = interaction.response.send_message.call_args
assert "Only the session owner" in msg_kwargs.args[0]
assert msg_kwargs.kwargs.get("ephemeral") is True
def test_legacy_footer_without_owner_rejected(self):
from turnstone.channels.discord.views import ApprovalView
view = ApprovalView(_make_view_bot())
# Pre-upgrade footer with only ws_id|correlation_id — fail closed.
interaction = _make_view_interaction(user_id=42, footer="ws-1|corr-1")
_run(view._handle(interaction, approved=True, always=False))
view.bot.router.send_approval.assert_not_awaited()
class TestPlanReviewViewOwnerCheck:
"""PlanReviewView rejects clicks from anyone other than the session owner."""
def test_owner_approve_allowed(self, monkeypatch):
from turnstone.channels.discord.views import PlanReviewView
monkeypatch.setattr(
"turnstone.channels.discord.views._disable_buttons",
AsyncMock(),
)
view = PlanReviewView(_make_view_bot())
interaction = _make_view_interaction(user_id=42, footer="ws-1|corr-1|42")
_run(view._handle_approve(interaction))
view.bot.router.send_plan_feedback.assert_awaited_once_with(
ws_id="ws-1",
correlation_id="corr-1",
feedback="",
)
def test_non_owner_approve_rejected(self):
from turnstone.channels.discord.views import PlanReviewView
view = PlanReviewView(_make_view_bot())
interaction = _make_view_interaction(user_id=999, footer="ws-1|corr-1|42")
_run(view._handle_approve(interaction))
view.bot.router.send_plan_feedback.assert_not_awaited()
interaction.response.send_message.assert_awaited_once()
def test_non_owner_changes_modal_rejected(self):
from turnstone.channels.discord.views import PlanReviewView
view = PlanReviewView(_make_view_bot())
interaction = _make_view_interaction(user_id=999, footer="ws-1|corr-1|42")
_run(view._handle_changes(interaction))
interaction.response.send_modal.assert_not_awaited()
interaction.response.send_message.assert_awaited_once()
class TestDiscordThreadOwnerCheck:
"""Sec-3 gate: only the thread creator can send messages into the workstream."""
@staticmethod
def _make_cog_and_ts():
"""Build a MessageCog wired to a minimal TurnstoneBot double."""
from turnstone.channels.discord.cog import MessageCog
bot = MagicMock()
bot.user = MagicMock()
bot.user.id = 99999
bot.user.mentioned_in = MagicMock(return_value=False)
ts = MagicMock()
ts._is_allowed_channel = MagicMock(return_value=True)
ts.storage = MagicMock()
ts.router = MagicMock()
ts.router.lookup_ws_id = AsyncMock(return_value="ws-1")
ts.router.resolve_user = AsyncMock(return_value="turnstone-user-1")
ts.router.send_message = AsyncMock()
ts.router.get_or_create_workstream = AsyncMock(return_value=("ws-1", False))
ts.config = MagicMock()
ts._ws_tasks = {}
ts._subscribed_ws = {"ws-1"}
ts._notify_ws_map = {}
ts._notify_reply_channels = {}
ts.get_thread_invoker = MagicMock(return_value=None)
ts.subscribe_ws = AsyncMock()
bot.turnstone = ts
return MessageCog(bot), ts
def test_non_owner_thread_message_dropped(self):
"""A linked user who is NOT the thread creator gets their message
silently dropped router.send_message must not fire."""
cog, ts = self._make_cog_and_ts()
# Build a thread whose owner_id is different from the message author.
thread = MagicMock(spec=discord.Thread)
thread.id = 555
thread.parent_id = 111
thread.owner_id = 42 # thread creator
thread.name = "some-thread"
msg = _make_message(guild=True, channel=thread)
msg.author.id = 999 # non-owner trying to inject
_run(cog._on_message(msg))
ts.router.send_message.assert_not_awaited()
ts.router.get_or_create_workstream.assert_not_awaited()
def test_ask_thread_followup_allowed_when_invoker_registered(self):
"""/ask creates threads with owner_id=bot; follow-ups from the
registered invoker must still reach the workstream."""
cog, ts = self._make_cog_and_ts()
# Simulate what _cmd_ask does after channel.create_thread().
ts.get_thread_invoker = MagicMock(return_value=111)
thread = MagicMock(spec=discord.Thread)
thread.id = 555
thread.parent_id = 222
thread.owner_id = 99999 # bot owns the thread after channel.create_thread
thread.name = "ask-thread"
msg = _make_message(guild=True, channel=thread)
msg.author.id = 111 # the human who ran /ask
_run(cog._on_message(msg))
ts.router.send_message.assert_awaited_once_with("ws-1", msg.content)
def test_ask_thread_rejects_other_user_even_when_invoker_registered(self):
"""Registered invoker lock: only that user's follow-ups pass."""
cog, ts = self._make_cog_and_ts()
ts.get_thread_invoker = MagicMock(return_value=111)
thread = MagicMock(spec=discord.Thread)
thread.id = 555
thread.parent_id = 222
thread.owner_id = 99999 # bot-owned
thread.name = "ask-thread"
msg = _make_message(guild=True, channel=thread)
msg.author.id = 222 # someone other than the recorded invoker
_run(cog._on_message(msg))
ts.router.send_message.assert_not_awaited()
+1 -55
View File
@@ -1,55 +1,13 @@
"""Tests for turnstone.channels._protocol and turnstone.channels._formatter."""
"""Tests for turnstone.channels._formatter."""
from __future__ import annotations
from turnstone.channels._formatter import (
chunk_message,
format_approval_request,
format_plan_review,
format_verdict,
truncate,
)
from turnstone.channels._protocol import ChannelEvent
# ---------------------------------------------------------------------------
# ChannelEvent
# ---------------------------------------------------------------------------
class TestChannelEvent:
def test_construction(self) -> None:
evt = ChannelEvent(
channel_type="discord",
channel_id="ch-1",
channel_user_id="u-42",
message="hello",
parent_channel_id="parent",
metadata={"key": "val"},
)
assert evt.channel_type == "discord"
assert evt.channel_id == "ch-1"
assert evt.channel_user_id == "u-42"
assert evt.message == "hello"
assert evt.parent_channel_id == "parent"
assert evt.metadata == {"key": "val"}
def test_defaults(self) -> None:
evt = ChannelEvent(
channel_type="slack",
channel_id="ch-2",
channel_user_id="u-7",
message="hi",
)
assert evt.parent_channel_id == ""
assert evt.metadata == {}
def test_metadata_independence(self) -> None:
"""Default metadata dicts are independent across instances."""
a = ChannelEvent(channel_type="x", channel_id="1", channel_user_id="u", message="m")
b = ChannelEvent(channel_type="x", channel_id="2", channel_user_id="u", message="m")
a.metadata["key"] = "val"
assert "key" not in b.metadata
# ---------------------------------------------------------------------------
# chunk_message
@@ -172,18 +130,6 @@ class TestFormatApprovalRequest:
assert "/etc/hosts" in result
# ---------------------------------------------------------------------------
# format_plan_review
# ---------------------------------------------------------------------------
class TestFormatPlanReview:
def test_format(self) -> None:
result = format_plan_review("Step 1: do stuff")
assert result.startswith("**Plan review requested:**")
assert "Step 1: do stuff" in result
# ---------------------------------------------------------------------------
# format_verdict
# ---------------------------------------------------------------------------
File diff suppressed because it is too large Load Diff
+397
View File
@@ -0,0 +1,397 @@
"""Tests for the shared SSE reconnect helper in turnstone.channels._sse."""
from __future__ import annotations
import asyncio
import contextlib
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest
def _run(coro): # type: ignore[no-untyped-def]
return asyncio.run(coro)
class _FakeSSEEvent:
"""A fake ``httpx_sse.ServerSentEvent`` with the subset we read."""
def __init__(self, event: str, data: str) -> None:
self.event = event
self.data = data
class _FakeEventSource:
"""Context manager returned by our fake ``aconnect_sse``.
Captures the (status_code, events) the test wants to deliver.
``aiter_sse`` yields the events then returns; the caller then hits
the outer ``while True`` loop again, which will pick up the next
queued response via the shared iterator state on _FakeConnect.
"""
def __init__(self, *, status_code: int, events: list[_FakeSSEEvent]) -> None:
self.response = SimpleNamespace(
status_code=status_code,
request=MagicMock(),
)
self._events = events
async def __aenter__(self) -> _FakeEventSource:
return self
async def __aexit__(self, exc_type, exc, tb) -> None: # noqa: ANN001
return None
async def aiter_sse(self): # type: ignore[no-untyped-def]
for event in self._events:
yield event
class _FakeConnect:
"""Drop-in replacement for ``httpx_sse.aconnect_sse``.
On each call, pops the next ``_FakeEventSource`` from *queue*. When
the queue is empty, raises ``asyncio.CancelledError`` so the loop
terminates cleanly in tests.
"""
def __init__(self, queue: list[_FakeEventSource]) -> None:
self._queue = queue
self.call_count = 0
def __call__(self, *args, **kwargs): # noqa: ANN001, ANN204
self.call_count += 1
if not self._queue:
raise asyncio.CancelledError
return self._queue.pop(0)
@pytest.fixture
def _fast_sleep(monkeypatch):
"""Patch asyncio.sleep so backoff doesn't actually wait; record calls."""
sleeps: list[float] = []
async def fake_sleep(delay: float) -> None:
sleeps.append(delay)
monkeypatch.setattr("turnstone.channels._sse.asyncio.sleep", fake_sleep)
return sleeps
def _valid_event_data(ws_id: str = "ws-1") -> str:
"""A payload ``ServerEvent.from_dict`` will accept (a ContentEvent)."""
return json.dumps(
{
"type": "content",
"ws_id": ws_id,
"text": "hello",
}
)
# ---------------------------------------------------------------------------
# 404 → on_stale + exit
# ---------------------------------------------------------------------------
class TestStaleRoute:
def test_404_calls_on_stale_and_returns(self, monkeypatch, _fast_sleep):
from turnstone.channels import _sse
queue = [_FakeEventSource(status_code=404, events=[])]
fake_connect = _FakeConnect(queue)
monkeypatch.setattr(_sse.httpx_sse, "aconnect_sse", fake_connect)
on_stale = AsyncMock()
on_event = AsyncMock()
async def node_url_fn(ws_id: str) -> str:
return "http://node"
_run(
_sse.run_sse_stream(
http_client=MagicMock(),
log_prefix="test",
ws_id="ws-1",
node_url_fn=node_url_fn,
token_factory=None,
on_event=on_event,
on_stale=on_stale,
)
)
on_stale.assert_awaited_once()
on_event.assert_not_awaited()
# No reconnect after 404.
assert fake_connect.call_count == 1
assert _fast_sleep == []
def test_on_stale_exception_still_exits(self, monkeypatch, _fast_sleep):
"""If on_stale raises, the loop must not reconnect."""
from turnstone.channels import _sse
queue = [_FakeEventSource(status_code=404, events=[])]
fake_connect = _FakeConnect(queue)
monkeypatch.setattr(_sse.httpx_sse, "aconnect_sse", fake_connect)
on_stale = AsyncMock(side_effect=RuntimeError("storage down"))
async def node_url_fn(ws_id: str) -> str:
return "http://node"
_run(
_sse.run_sse_stream(
http_client=MagicMock(),
log_prefix="test",
ws_id="ws-1",
node_url_fn=node_url_fn,
token_factory=None,
on_event=AsyncMock(),
on_stale=on_stale,
)
)
on_stale.assert_awaited_once()
# Still a single connect — no livelock.
assert fake_connect.call_count == 1
# ---------------------------------------------------------------------------
# 500+ → exponential backoff
# ---------------------------------------------------------------------------
class TestBackoff:
def test_500_triggers_backoff_and_retries(self, monkeypatch, _fast_sleep):
from turnstone.channels import _sse
queue = [
_FakeEventSource(status_code=503, events=[]),
_FakeEventSource(status_code=503, events=[]),
_FakeEventSource(status_code=503, events=[]),
]
fake_connect = _FakeConnect(queue)
monkeypatch.setattr(_sse.httpx_sse, "aconnect_sse", fake_connect)
async def node_url_fn(ws_id: str) -> str:
return "http://node"
with contextlib.suppress(asyncio.CancelledError):
_run(
_sse.run_sse_stream(
http_client=MagicMock(),
log_prefix="test",
ws_id="ws-1",
node_url_fn=node_url_fn,
token_factory=None,
on_event=AsyncMock(),
on_stale=AsyncMock(),
)
)
assert fake_connect.call_count >= 3
# First three recorded sleeps are 2s, 4s, 8s (starts at
# SSE_RECONNECT_DELAY, doubles each time, capped at
# SSE_MAX_RECONNECT_DELAY).
assert _fast_sleep[0] == _sse.SSE_RECONNECT_DELAY
assert _fast_sleep[1] == _sse.SSE_RECONNECT_DELAY * 2
assert _fast_sleep[2] == _sse.SSE_RECONNECT_DELAY * 4
def test_backoff_resets_after_successful_dispatch(self, monkeypatch, _fast_sleep):
"""After a 200 + successful event dispatch, the next error
restarts backoff at the initial delay."""
from turnstone.channels import _sse
good_event = _FakeSSEEvent(event="message", data=_valid_event_data())
queue = [
_FakeEventSource(status_code=503, events=[]),
_FakeEventSource(status_code=200, events=[good_event]),
_FakeEventSource(status_code=503, events=[]),
]
fake_connect = _FakeConnect(queue)
monkeypatch.setattr(_sse.httpx_sse, "aconnect_sse", fake_connect)
on_event = AsyncMock()
async def node_url_fn(ws_id: str) -> str:
return "http://node"
with contextlib.suppress(asyncio.CancelledError):
_run(
_sse.run_sse_stream(
http_client=MagicMock(),
log_prefix="test",
ws_id="ws-1",
node_url_fn=node_url_fn,
token_factory=None,
on_event=on_event,
on_stale=AsyncMock(),
)
)
on_event.assert_awaited()
# Sleep sequence: 2 (after first 503), 2 (reset after 200/event),
# then CancelledError exits. First two sleeps are both the base
# delay — the reset did its job.
assert len(_fast_sleep) >= 2
assert _fast_sleep[0] == _sse.SSE_RECONNECT_DELAY
assert _fast_sleep[1] == _sse.SSE_RECONNECT_DELAY
# ---------------------------------------------------------------------------
# Event dispatch
# ---------------------------------------------------------------------------
class TestEventDispatch:
def test_invalid_json_is_skipped(self, monkeypatch, _fast_sleep):
from turnstone.channels import _sse
bad = _FakeSSEEvent(event="message", data="{not json")
good = _FakeSSEEvent(event="message", data=_valid_event_data())
queue = [_FakeEventSource(status_code=200, events=[bad, good])]
fake_connect = _FakeConnect(queue)
monkeypatch.setattr(_sse.httpx_sse, "aconnect_sse", fake_connect)
on_event = AsyncMock()
async def node_url_fn(ws_id: str) -> str:
return "http://node"
with contextlib.suppress(asyncio.CancelledError):
_run(
_sse.run_sse_stream(
http_client=MagicMock(),
log_prefix="test",
ws_id="ws-1",
node_url_fn=node_url_fn,
token_factory=None,
on_event=on_event,
on_stale=AsyncMock(),
)
)
# Good event delivered, bad one silently dropped.
assert on_event.await_count == 1
def test_on_event_exception_does_not_kill_stream(self, monkeypatch, _fast_sleep):
from turnstone.channels import _sse
e1 = _FakeSSEEvent(event="message", data=_valid_event_data())
e2 = _FakeSSEEvent(event="message", data=_valid_event_data())
queue = [_FakeEventSource(status_code=200, events=[e1, e2])]
fake_connect = _FakeConnect(queue)
monkeypatch.setattr(_sse.httpx_sse, "aconnect_sse", fake_connect)
on_event = AsyncMock(side_effect=[RuntimeError("boom"), None])
async def node_url_fn(ws_id: str) -> str:
return "http://node"
with contextlib.suppress(asyncio.CancelledError):
_run(
_sse.run_sse_stream(
http_client=MagicMock(),
log_prefix="test",
ws_id="ws-1",
node_url_fn=node_url_fn,
token_factory=None,
on_event=on_event,
on_stale=AsyncMock(),
)
)
# Both events attempted — first raised but second still delivered.
assert on_event.await_count == 2
# ---------------------------------------------------------------------------
# Token factory
# ---------------------------------------------------------------------------
class TestTokenFactory:
def test_header_refreshed_per_connection(self, monkeypatch, _fast_sleep):
"""token_factory is called once per reconnect so rotating service
JWTs stay fresh."""
from turnstone.channels import _sse
# Two reconnects followed by CancelledError to exit.
queue = [
_FakeEventSource(status_code=503, events=[]),
_FakeEventSource(status_code=503, events=[]),
]
fake_connect = _FakeConnect(queue)
monkeypatch.setattr(_sse.httpx_sse, "aconnect_sse", fake_connect)
tokens: list[str] = []
def factory() -> str:
tok = f"tok-{len(tokens)}"
tokens.append(tok)
return tok
async def node_url_fn(ws_id: str) -> str:
return "http://node"
with contextlib.suppress(asyncio.CancelledError):
_run(
_sse.run_sse_stream(
http_client=MagicMock(),
log_prefix="test",
ws_id="ws-1",
node_url_fn=node_url_fn,
token_factory=factory,
on_event=AsyncMock(),
on_stale=AsyncMock(),
)
)
assert len(tokens) >= 2
assert tokens[0] != tokens[1]
# ---------------------------------------------------------------------------
# httpx errors
# ---------------------------------------------------------------------------
class TestTransportErrors:
def test_connect_error_falls_through_to_backoff(self, monkeypatch, _fast_sleep):
"""ConnectError is caught and treated as retryable."""
from turnstone.channels import _sse
call_order = {"n": 0}
def fake_connect(*args, **kwargs): # noqa: ANN001, ANN003
call_order["n"] += 1
if call_order["n"] == 1:
raise httpx.ConnectError("boom")
# Second attempt: signal the loop to exit.
raise asyncio.CancelledError
monkeypatch.setattr(_sse.httpx_sse, "aconnect_sse", fake_connect)
async def node_url_fn(ws_id: str) -> str:
return "http://node"
with contextlib.suppress(asyncio.CancelledError):
_run(
_sse.run_sse_stream(
http_client=MagicMock(),
log_prefix="test",
ws_id="ws-1",
node_url_fn=node_url_fn,
token_factory=None,
on_event=AsyncMock(),
on_stale=AsyncMock(),
)
)
assert call_order["n"] == 2
# Backoff ran once after the ConnectError.
assert _fast_sleep == [_sse.SSE_RECONNECT_DELAY]
+184
View File
@@ -0,0 +1,184 @@
"""Server-side tests for the close_workstream handler's close_reason
persistence guards the seam that lets coordinator inspect surface
why a workstream was retired without scraping the audit log.
"""
from __future__ import annotations
import queue
import threading
from typing import Any
from unittest.mock import MagicMock
import pytest
from starlette.testclient import TestClient
import turnstone.server as srv_mod
from turnstone.core.auth import JWT_AUD_SERVER, create_jwt
from turnstone.core.metrics import MetricsCollector
from turnstone.core.storage._sqlite import SQLiteBackend
from turnstone.core.workstream import WorkstreamState
_JWT_SECRET = "test-jwt-secret-minimum-32-chars!"
def _full_hdr() -> dict[str, str]:
return {
"Authorization": (
f"Bearer {create_jwt('u1', frozenset({'read', 'write', 'approve'}), 'test', _JWT_SECRET, audience=JWT_AUD_SERVER)}"
)
}
def _make_app(storage: Any) -> TestClient:
srv_mod._metrics = MetricsCollector()
srv_mod._metrics.model = "test-model"
mock_session = MagicMock()
mock_ws = MagicMock()
mock_ws.id = "ws-target"
mock_ws.name = "test"
mock_ws.state = WorkstreamState.IDLE
mock_ws.session = mock_session
# Tenant gate (#375) checks ws.user_id == JWT subject; explicit set
# so MagicMock's auto-generated truthy attribute doesn't reject the
# request before the persistence path runs. kind / parent_ws_id
# land in the audit_detail dict alongside ``reason``.
mock_ws.user_id = "u1"
mock_ws.kind = "interactive"
mock_ws.parent_ws_id = None
mock_mgr = MagicMock()
mock_mgr.get.return_value = mock_ws
mock_mgr.close.return_value = True
mock_mgr.list_all.return_value = [mock_ws]
mock_mgr.max_active = 10
app = srv_mod.create_app(
workstreams=mock_mgr,
global_queue=queue.Queue(),
global_listeners=[],
global_listeners_lock=threading.Lock(),
skip_permissions=False,
jwt_secret=_JWT_SECRET,
auth_storage=storage,
cors_origins=["*"],
)
return TestClient(app, raise_server_exceptions=False)
@pytest.fixture
def storage(tmp_path):
return SQLiteBackend(str(tmp_path / "close.db"))
def test_close_with_reason_persists_to_workstream_config(storage):
client = _make_app(storage)
resp = client.post(
"/v1/api/workstreams/ws-target/close",
json={"reason": "task complete"},
headers=_full_hdr(),
)
assert resp.status_code == 200
cfg = storage.load_workstream_config("ws-target")
assert cfg.get("close_reason") == "task complete"
def test_close_without_reason_does_not_touch_config(storage):
client = _make_app(storage)
resp = client.post(
"/v1/api/workstreams/ws-target/close",
json={},
headers=_full_hdr(),
)
assert resp.status_code == 200
cfg = storage.load_workstream_config("ws-target")
assert "close_reason" not in cfg
def test_close_reason_capped_at_512_bytes(storage):
"""A model that dumps a multi-KB blob (or a captured secret) into the
close reason must not be able to grow the workstream_config row
without bound the handler enforces a 512-byte ceiling. Tested
with ASCII (1B/char) so the byte cap and char count coincide."""
huge = "x" * 5000
client = _make_app(storage)
resp = client.post(
"/v1/api/workstreams/ws-target/close",
json={"reason": huge},
headers=_full_hdr(),
)
assert resp.status_code == 200
cfg = storage.load_workstream_config("ws-target")
stored = cfg.get("close_reason")
assert stored is not None
assert len(stored.encode("utf-8")) <= 512
def test_close_reason_byte_cap_holds_for_multibyte_utf8(storage):
"""Repro for the char-cap-vs-byte-cap mismatch: a CJK-only payload
of 600 chars would have leaked through a code-point slice at
600*3=1800 bytes. The byte-aware cap holds it at <=512 bytes."""
huge = "\u6f22" * 600 # 3 bytes/char in UTF-8
client = _make_app(storage)
resp = client.post(
"/v1/api/workstreams/ws-target/close",
json={"reason": huge},
headers=_full_hdr(),
)
assert resp.status_code == 200
cfg = storage.load_workstream_config("ws-target")
stored = cfg.get("close_reason")
assert stored is not None
assert len(stored.encode("utf-8")) <= 512
def test_close_with_non_string_reason_drops_silently(storage):
"""A malformed body (reason=dict / list / int) should not crash the
handler non-string reasons are coerced to empty and the close
proceeds without writing to workstream_config."""
client = _make_app(storage)
resp = client.post(
"/v1/api/workstreams/ws-target/close",
json={"reason": {"unexpected": "shape"}},
headers=_full_hdr(),
)
assert resp.status_code == 200
cfg = storage.load_workstream_config("ws-target")
assert "close_reason" not in cfg
def test_close_reason_redacts_credentials(storage):
"""A model under prompt injection that captures a secret and stuffs
it into ``reason`` must not get to plant the plaintext secret in
audit logs / workstream_config. The output guard's credential-
redaction pass runs at the close handler boundary."""
client = _make_app(storage)
secret = "AKIAIOSFODNN7EXAMPLE" # AWS access key — output guard catches.
resp = client.post(
"/v1/api/workstreams/ws-target/close",
json={"reason": f"task done; key={secret}"},
headers=_full_hdr(),
)
assert resp.status_code == 200
cfg = storage.load_workstream_config("ws-target")
stored = cfg.get("close_reason")
assert stored is not None
assert secret not in stored
assert "[REDACTED:" in stored
def test_close_reason_persistence_failure_does_not_block_close(storage):
"""If the storage save raises, the close still succeeds — persistence
is best-effort; a transient storage error must not block the user
from closing a workstream."""
client = _make_app(storage)
def _boom(*args, **kwargs):
raise RuntimeError("storage down")
storage.save_workstream_config = _boom # type: ignore[method-assign]
resp = client.post(
"/v1/api/workstreams/ws-target/close",
json={"reason": "task complete"},
headers=_full_hdr(),
)
assert resp.status_code == 200
+4 -1
View File
@@ -3,7 +3,10 @@
import argparse
import turnstone.core.config as config_mod
from turnstone.core.config import apply_config, load_config, set_config_path
apply_config = config_mod.apply_config
load_config = config_mod.load_config
set_config_path = config_mod.set_config_path
def _reset_cache():
+19 -6
View File
@@ -87,8 +87,20 @@ class TestSetGetRoundTrip:
assert store.get("tools.skip_permissions") is False
def test_str(self, store):
store.set("model.name", "gpt-5")
assert store.get("model.name") == "gpt-5"
store.set("model.default_alias", "gpt5-prod")
assert store.get("model.default_alias") == "gpt5-prod"
def test_plan_task_alias(self, store):
store.set("model.plan_alias", "smart")
store.set("model.task_alias", "fast")
assert store.get("model.plan_alias") == "smart"
assert store.get("model.task_alias") == "fast"
def test_plan_task_effort(self, store):
store.set("model.plan_effort", "max")
store.set("model.task_effort", "low")
assert store.get("model.plan_effort") == "max"
assert store.get("model.task_effort") == "low"
# ---------------------------------------------------------------------------
@@ -105,7 +117,8 @@ class TestDelete:
assert store.get("tools.timeout") == defn.default
def test_returns_false_for_non_existent(self, store):
assert store.delete("tools.timeout") is False
result = store.delete("tools.timeout")
assert result is False
def test_rejects_unknown_key(self, store):
with pytest.raises(ValueError, match="Unknown setting"):
@@ -164,10 +177,10 @@ class TestStoredKeys:
assert store.stored_keys() == frozenset()
store.set("tools.timeout", 30)
assert store.stored_keys() == frozenset({"tools.timeout"})
store.set("model.name", "gpt-5")
assert store.stored_keys() == frozenset({"tools.timeout", "model.name"})
store.set("model.default_alias", "gpt5-prod")
assert store.stored_keys() == frozenset({"tools.timeout", "model.default_alias"})
store.delete("tools.timeout")
assert store.stored_keys() == frozenset({"model.name"})
assert store.stored_keys() == frozenset({"model.default_alias"})
# ---------------------------------------------------------------------------
+124 -23
View File
@@ -31,16 +31,7 @@ _TEST_AUTH_HEADERS = {"Authorization": f"Bearer {_test_jwt()}"}
# Mock storage for collector tests
# ---------------------------------------------------------------------------
class MockStorage:
"""Minimal storage mock that implements list_services for collector tests."""
def __init__(self):
self.services: list[dict[str, str]] = []
def list_services(self, service_type: str, max_age_seconds: int = 120) -> list[dict[str, str]]:
return [s for s in self.services if True] # all services match
from tests._coord_test_helpers import MockStorage # noqa: E402, F401
# ---------------------------------------------------------------------------
# Helpers
@@ -418,13 +409,12 @@ class TestCollectorDelta:
c._nodes["node-a"] = NodeSnapshot(
node_id="node-a",
server_url="http://a:8080",
health={"status": "ok", "backend": {"status": "up", "circuit_state": "closed"}},
health={"status": "ok", "backend": {"status": "up"}},
)
c._apply_delta("node-a", {"type": "health_changed", "circuit_state": "open"})
c._apply_delta("node-a", {"type": "health_changed", "backend_status": "degraded"})
health = c._nodes["node-a"].health
assert health["backend"]["circuit_state"] == "open"
assert health["backend"]["status"] == "down"
assert health["status"] == "degraded"
@@ -758,7 +748,9 @@ class TestConsoleHTTPEndpoints:
assert status == 200
assert len(data["nodes"]) == 1
assert data["total"] == 1
mock_collector.get_nodes.assert_called_once_with(sort_by="activity", limit=10, offset=0)
mock_collector.get_nodes.assert_called_once_with(
sort_by="activity", limit=10, offset=0, node_ids=None
)
def test_get_workstreams(self, client, mock_collector):
status, data = self._get(
@@ -776,6 +768,7 @@ class TestConsoleHTTPEndpoints:
sort_by="state",
page=1,
per_page=25,
extra_rows=[],
)
def test_get_workstreams_per_page_capped(self, client, mock_collector):
@@ -832,16 +825,18 @@ class TestConsoleHTTPEndpoints:
resp = client.get("/nonexistent")
assert resp.status_code == 404
def test_index_has_new_ws_button(self, client):
def test_index_landing_surfaces(self, client):
status, body, ct = self._get_raw(client, "/")
assert status == 200
assert 'id="new-ws-btn"' in body
assert "showNewWsModal" in body
def test_index_has_new_ws_modal(self, client):
status, body, ct = self._get_raw(client, "/")
assert 'id="new-ws-overlay"' in body
assert 'id="new-ws-node"' in body
# Coordinator-first landing keeps the node list always-visible.
assert 'id="view-overview"' in body
assert 'id="node-table"' in body
# Removed in the 1.5.0 landing-page cleanup — guard against
# accidental reintroduction.
assert 'id="new-ws-overlay"' not in body
assert 'id="new-ws-btn"' not in body
assert 'id="cluster-summary-compact"' not in body
assert 'id="view-node"' not in body
# ---------------------------------------------------------------------------
@@ -1199,6 +1194,98 @@ class TestConsoleProxy:
)
assert resp.status_code == 404
def test_proxy_api_per_ws_events_routes_to_sse_handler(self, client, mock_collector):
"""``/node/{node_id}/v1/api/workstreams/{ws_id}/events`` is the
per-workstream SSE stream the interactive WebUI subscribes to.
Without explicit detection, the path falls through to the
regular GET branch and the EventSource API can't consume the
one-shot response Firefox surfaces it as "can't establish a
connection". Regression guard for the legacy URL surface
removal (#422) that moved per-ws SSE under
``/workstreams/{ws_id}/events`` without updating the proxy."""
from unittest.mock import AsyncMock, patch
from starlette.responses import Response
mock_collector.get_node_detail.return_value = {
"node_id": "node-a",
"server_url": "http://a:8080",
"reachable": True,
}
ws_id = "a" * 32
with (
patch(
"turnstone.console.server._proxy_sse",
new_callable=AsyncMock,
return_value=Response("ok", status_code=200),
) as sse_mock,
patch(
"turnstone.console.server._proxy_get",
new_callable=AsyncMock,
return_value=Response("ok", status_code=200),
) as get_mock,
):
client.get(f"/node/node-a/v1/api/workstreams/{ws_id}/events")
assert sse_mock.await_count == 1, (
"per-ws events path must route to _proxy_sse, not _proxy_get"
)
assert get_mock.await_count == 0
# Path passed to _proxy_sse must be the workstreams-prefixed
# form so the upstream URL is reconstructed correctly.
sse_args = sse_mock.await_args
assert sse_args.args[2] == f"workstreams/{ws_id}/events"
def test_proxy_api_global_events_still_routes_to_sse(self, client, mock_collector):
"""The bare ``events/global`` path was the only SSE path the
proxy recognized before the per-ws fix. Verify it still routes
correctly so the new branch didn't regress the existing case."""
from unittest.mock import AsyncMock, patch
from starlette.responses import Response
mock_collector.get_node_detail.return_value = {
"node_id": "node-a",
"server_url": "http://a:8080",
"reachable": True,
}
with patch(
"turnstone.console.server._proxy_sse",
new_callable=AsyncMock,
return_value=Response("ok", status_code=200),
) as sse_mock:
client.get("/node/node-a/v1/api/events/global")
assert sse_mock.await_count == 1
# events/global must use the console's service token —
# the upstream gates this path on `service` scope and
# end-user JWTs don't carry it. Without this, the
# browser's interactive UI 403-loops on every retry.
assert sse_mock.await_args.kwargs.get("use_service_auth") is True
def test_proxy_api_per_ws_events_uses_user_auth_not_service(self, client, mock_collector):
"""Per-ws events route uses the user's re-minted JWT, not the
service token the upstream per-ws SSE handler scopes by
user identity for tenant filtering, and a service-scoped
call would bypass that gate. Only ``events/global``
(cross-tenant inventory by design) opts into service auth."""
from unittest.mock import AsyncMock, patch
from starlette.responses import Response
mock_collector.get_node_detail.return_value = {
"node_id": "node-a",
"server_url": "http://a:8080",
"reachable": True,
}
ws_id = "b" * 32
with patch(
"turnstone.console.server._proxy_sse",
new_callable=AsyncMock,
return_value=Response("ok", status_code=200),
) as sse_mock:
client.get(f"/node/node-a/v1/api/workstreams/{ws_id}/events")
assert sse_mock.await_count == 1
assert sse_mock.await_args.kwargs.get("use_service_auth") is False
# ---------------------------------------------------------------------------
# Proxy URL rewriting unit tests (no HTTP needed)
@@ -1428,7 +1515,7 @@ class TestSharedStatic:
def test_index_imports_shared_base_css(self, client):
resp = client.get("/")
assert resp.status_code == 200
assert '/shared/base.css"' in resp.text
assert "/shared/base.css?v=" in resp.text
def test_index_imports_shared_scripts(self, client):
resp = client.get("/")
@@ -1446,6 +1533,20 @@ class TestSharedStatic:
app_pos = body.find("/static/app.js")
assert shared_pos < app_pos
def test_index_cache_control_no_cache(self, client):
resp = client.get("/")
assert resp.headers.get("cache-control") == "no-cache"
def test_index_etag_present(self, client):
resp = client.get("/")
assert resp.headers.get("etag")
def test_index_etag_304(self, client):
resp = client.get("/")
etag = resp.headers.get("etag")
resp2 = client.get("/", headers={"If-None-Match": etag})
assert resp2.status_code == 304
class TestProxySharedStatic:
"""Tests for proxy rewriting of /shared/ paths."""
+43 -65
View File
@@ -37,61 +37,49 @@ class TestRecordRoute:
assert "turnstone_router_request_duration_seconds_sum" in text
class TestRingInfo:
"""Ring membership and version gauges."""
class TestRecordJudgeVerdict:
"""Coord-side intent-judge verdict counter."""
def test_single_verdict(self) -> None:
m = ConsoleMetrics()
m.record_judge_verdict("heuristic", "high", 12)
text = m.generate_text()
assert 'turnstone_judge_verdicts_total{tier="heuristic",risk_level="high"} 1' in text
def test_aggregates_by_tier_and_risk(self) -> None:
m = ConsoleMetrics()
m.record_judge_verdict("heuristic", "low", 5)
m.record_judge_verdict("heuristic", "low", 7)
m.record_judge_verdict("llm", "high", 250)
text = m.generate_text()
assert 'turnstone_judge_verdicts_total{tier="heuristic",risk_level="low"} 2' in text
assert 'turnstone_judge_verdicts_total{tier="llm",risk_level="high"} 1' in text
def test_section_omitted_when_empty(self) -> None:
"""No verdicts recorded → don't emit the empty header block."""
m = ConsoleMetrics()
text = m.generate_text()
assert "turnstone_judge_verdicts_total" not in text
class TestRouterInfo:
"""Live-membership gauge + refresh counter."""
def test_defaults_zero(self) -> None:
m = ConsoleMetrics()
text = m.generate_text()
assert "turnstone_ring_membership_size 0" in text
assert "turnstone_ring_version 0" in text
assert "turnstone_router_membership_size 0" in text
assert "turnstone_router_refresh_total 0" in text
def test_set_ring_info(self) -> None:
def test_set_router_info(self) -> None:
m = ConsoleMetrics()
m.set_ring_info(3, 7)
m.set_router_info(3, 7)
text = m.generate_text()
assert "turnstone_ring_membership_size 3" in text
assert "turnstone_ring_version 7" in text
class TestRebalance:
"""Rebalance and migration counters."""
def test_noop(self) -> None:
m = ConsoleMetrics()
m.record_rebalance("noop")
text = m.generate_text()
assert 'turnstone_ring_rebalance_total{result="noop"} 1' in text
def test_seeded(self) -> None:
m = ConsoleMetrics()
m.record_rebalance("seeded")
text = m.generate_text()
assert 'turnstone_ring_rebalance_total{result="seeded"} 1' in text
def test_rebalanced(self) -> None:
m = ConsoleMetrics()
m.record_rebalance("rebalanced")
m.record_rebalance("rebalanced")
text = m.generate_text()
assert 'turnstone_ring_rebalance_total{result="rebalanced"} 2' in text
def test_migrations(self) -> None:
m = ConsoleMetrics()
m.record_migrations(5)
m.record_migrations(3)
text = m.generate_text()
assert "turnstone_ring_migrations_total 8" in text
def test_migrations_default_zero(self) -> None:
m = ConsoleMetrics()
text = m.generate_text()
assert "turnstone_ring_migrations_total 0" in text
assert "turnstone_router_membership_size 3" in text
assert "turnstone_router_refresh_total 7" in text
class TestGenerateText:
@@ -103,10 +91,8 @@ class TestGenerateText:
expected = [
"turnstone_router_requests_total",
"turnstone_router_request_duration_seconds",
"turnstone_ring_membership_size",
"turnstone_ring_version",
"turnstone_ring_rebalance_total",
"turnstone_ring_migrations_total",
"turnstone_router_membership_size",
"turnstone_router_refresh_total",
]
for name in expected:
assert name in text, f"Missing metric: {name}"
@@ -116,8 +102,8 @@ class TestGenerateText:
text = m.generate_text()
assert "# HELP turnstone_router_requests_total" in text
assert "# TYPE turnstone_router_requests_total counter" in text
assert "# HELP turnstone_ring_membership_size" in text
assert "# TYPE turnstone_ring_membership_size gauge" in text
assert "# HELP turnstone_router_membership_size" in text
assert "# TYPE turnstone_router_membership_size gauge" in text
def test_ends_with_newline(self) -> None:
m = ConsoleMetrics()
@@ -125,24 +111,16 @@ class TestGenerateText:
assert text.endswith("\n")
def test_combined_scenario(self) -> None:
"""Full scenario: routes, ring info, rebalances, migrations."""
"""Full scenario: routes + router info."""
m = ConsoleMetrics()
m.record_route("create", 200, 0.1)
m.record_route("send", 200, 0.05)
m.record_route("send", 502, 1.2)
m.set_ring_info(3, 12)
m.record_rebalance("seeded")
m.record_rebalance("noop")
m.record_rebalance("rebalanced")
m.record_migrations(4)
m.set_router_info(3, 12)
text = m.generate_text()
assert 'turnstone_router_requests_total{method="create",status="2xx"} 1' in text
assert 'turnstone_router_requests_total{method="send",status="2xx"} 1' in text
assert 'turnstone_router_requests_total{method="send",status="5xx"} 1' in text
assert "turnstone_ring_membership_size 3" in text
assert "turnstone_ring_version 12" in text
assert 'turnstone_ring_rebalance_total{result="noop"} 1' in text
assert 'turnstone_ring_rebalance_total{result="rebalanced"} 1' in text
assert 'turnstone_ring_rebalance_total{result="seeded"} 1' in text
assert "turnstone_ring_migrations_total 4" in text
assert "turnstone_router_membership_size 3" in text
assert "turnstone_router_refresh_total 12" in text
+353
View File
@@ -0,0 +1,353 @@
"""Tests for console routing of attachment endpoints + multipart route_create.
Covers the cluster-routing surface added alongside the workstream
attachment-on-create feature: the multipart variant of route_create and
the four ws-id-keyed attachment proxies under /v1/api/route/.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock
import httpx
from starlette.testclient import TestClient
from turnstone.console.collector import ClusterCollector
from turnstone.console.router import ConsoleRouter, NodeRef
_TEST_JWT_SECRET = "test-jwt-secret-minimum-32-chars!"
def _test_jwt() -> str:
from turnstone.core.auth import JWT_AUD_CONSOLE, create_jwt
return create_jwt(
user_id="test-routing",
scopes=frozenset({"read", "write", "approve", "service"}),
source="test",
secret=_TEST_JWT_SECRET,
audience=JWT_AUD_CONSOLE,
)
_AUTH: dict[str, str] = {"Authorization": f"Bearer {_test_jwt()}"}
def _make_app(router: Any) -> Any:
from turnstone.console.server import _load_static, create_app
_load_static()
collector = MagicMock(spec=ClusterCollector)
return create_app(
collector=collector,
jwt_secret=_TEST_JWT_SECRET,
router=router,
)
def _make_router() -> MagicMock:
router = MagicMock(spec=ConsoleRouter)
router.is_ready.return_value = True
router.route.return_value = NodeRef("node-a", "http://a:8080")
return router
# ---------------------------------------------------------------------------
# route_create multipart
# ---------------------------------------------------------------------------
class TestRouteCreateMultipart:
def test_multipart_requires_ws_id_query(self):
router = _make_router()
app = _make_app(router=router)
app.state.proxy_client = MagicMock(spec=httpx.AsyncClient)
client = TestClient(app, raise_server_exceptions=False)
try:
resp = client.post(
"/v1/api/route/workstreams/new",
files=[("file", ("a.txt", b"hello", "text/plain"))],
data={"meta": "{}"},
headers=_AUTH,
)
assert resp.status_code == 400
assert "ws_id" in resp.json()["error"]
finally:
client.close()
def test_multipart_forwards_raw_body_to_routed_node(self):
router = _make_router()
app = _make_app(router=router)
captured: dict[str, Any] = {}
async def _mock_post(*args: Any, **kwargs: Any) -> httpx.Response:
captured["url"] = args[0] if args else ""
captured["headers"] = kwargs.get("headers") or {}
captured["content"] = kwargs.get("content")
return httpx.Response(
200,
json={"ws_id": "00ff" + "0" * 28, "name": "demo"},
request=httpx.Request("POST", args[0] if args else "http://test"),
)
mock_proxy = MagicMock(spec=httpx.AsyncClient)
mock_proxy.post = MagicMock(side_effect=_mock_post)
app.state.proxy_client = mock_proxy
client = TestClient(app, raise_server_exceptions=False)
try:
ws_id = "00ff" + "0" * 28
resp = client.post(
f"/v1/api/route/workstreams/new?ws_id={ws_id}",
files=[("file", ("a.txt", b"hello", "text/plain"))],
data={"meta": '{"name":"demo"}'},
headers=_AUTH,
)
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["node_id"] == "node-a"
# Forwarded multipart Content-Type
assert captured["headers"].get("Content-Type", "").startswith("multipart/form-data")
# Body bytes were forwarded raw
assert isinstance(captured["content"], (bytes, bytearray))
assert b"hello" in bytes(captured["content"])
router.route.assert_called_with(ws_id)
finally:
client.close()
def test_multipart_preserves_mixed_case_boundary(self):
"""The boundary= param is case-sensitive — must match body bytes verbatim.
Regression for an earlier bug where route_create lowercased the
whole Content-Type header before forwarding, mangling boundaries
like ``WebKitFormBoundary7MA4YWxkTrZu0gW``.
"""
router = _make_router()
app = _make_app(router=router)
captured: dict[str, Any] = {}
async def _mock_post(*args: Any, **kwargs: Any) -> httpx.Response:
captured["headers"] = kwargs.get("headers") or {}
captured["content"] = kwargs.get("content")
return httpx.Response(
200,
json={"ws_id": "00ff" + "0" * 28, "name": "ok"},
request=httpx.Request("POST", args[0] if args else "http://test"),
)
mock_proxy = MagicMock(spec=httpx.AsyncClient)
mock_proxy.post = MagicMock(side_effect=_mock_post)
app.state.proxy_client = mock_proxy
client = TestClient(app, raise_server_exceptions=False)
try:
ws_id = "00ff" + "0" * 28
boundary = "WebKitFormBoundary7MA4YWxkTrZu0gW" # mixed-case
body = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="meta"\r\n\r\n'
f'{{"name":"demo"}}\r\n'
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="file"; filename="a.txt"\r\n'
f"Content-Type: text/plain\r\n\r\n"
f"hello\r\n"
f"--{boundary}--\r\n"
).encode()
resp = client.post(
f"/v1/api/route/workstreams/new?ws_id={ws_id}",
content=body,
headers={
**_AUTH,
"Content-Type": f"multipart/form-data; boundary={boundary}",
},
)
assert resp.status_code == 200, resp.text
forwarded = captured["headers"].get("Content-Type", "")
assert boundary in forwarded, (
f"boundary mangled in upstream Content-Type: {forwarded!r}"
)
# Body bytes still contain the mixed-case boundary
assert boundary.encode() in bytes(captured["content"])
finally:
client.close()
def test_json_path_unchanged(self):
"""Existing JSON callers should continue to work as before."""
router = _make_router()
app = _make_app(router=router)
async def _mock_post(*args: Any, **kwargs: Any) -> httpx.Response:
return httpx.Response(
200,
json={"ws_id": "abc123", "name": "json"},
request=httpx.Request("POST", args[0] if args else "http://test"),
)
mock_proxy = MagicMock(spec=httpx.AsyncClient)
mock_proxy.post = MagicMock(side_effect=_mock_post)
app.state.proxy_client = mock_proxy
client = TestClient(app, raise_server_exceptions=False)
try:
resp = client.post(
"/v1/api/route/workstreams/new",
json={"name": "json"},
headers=_AUTH,
)
assert resp.status_code == 200
assert resp.json()["ws_id"] == "abc123"
# JSON path uses json= kwarg, not content=
call_kwargs = mock_proxy.post.call_args.kwargs
assert "json" in call_kwargs
assert "content" not in call_kwargs
finally:
client.close()
# ---------------------------------------------------------------------------
# route_attachment_proxy
# ---------------------------------------------------------------------------
class TestRouteAttachmentProxy:
def _wire(self, mock_request_fn) -> tuple[Any, MagicMock]:
router = _make_router()
app = _make_app(router=router)
mock_proxy = MagicMock(spec=httpx.AsyncClient)
mock_proxy.request = MagicMock(side_effect=mock_request_fn)
mock_proxy.get = MagicMock(side_effect=mock_request_fn)
mock_proxy.post = MagicMock(side_effect=mock_request_fn)
app.state.proxy_client = mock_proxy
return app, mock_proxy
def test_upload_proxies_multipart(self):
captured: dict[str, Any] = {}
async def _mock(*args: Any, **kwargs: Any) -> httpx.Response:
captured["method"] = args[0] if args else kwargs.get("method")
captured["url"] = args[1] if len(args) > 1 else kwargs.get("url", "")
captured["headers"] = kwargs.get("headers") or {}
captured["content"] = kwargs.get("content")
return httpx.Response(
200,
json={
"attachment_id": "att-1",
"filename": "a.txt",
"mime_type": "text/plain",
"size_bytes": 5,
"kind": "text",
},
request=httpx.Request("POST", "http://a:8080/x"),
)
app, _ = self._wire(_mock)
client = TestClient(app, raise_server_exceptions=False)
try:
resp = client.post(
"/v1/api/route/workstreams/ws-X/attachments",
files=[("file", ("a.txt", b"hello", "text/plain"))],
headers=_AUTH,
)
assert resp.status_code == 200
assert resp.json()["attachment_id"] == "att-1"
assert "/v1/api/workstreams/ws-X/attachments" in captured["url"]
assert "/route/" not in captured["url"]
assert captured["headers"].get("Content-Type", "").startswith("multipart/form-data")
finally:
client.close()
def test_list_proxies_get(self):
async def _mock(*args: Any, **kwargs: Any) -> httpx.Response:
return httpx.Response(
200,
json={"attachments": []},
request=httpx.Request("GET", "http://a:8080/x"),
)
app, mock_proxy = self._wire(_mock)
client = TestClient(app, raise_server_exceptions=False)
try:
resp = client.get(
"/v1/api/route/workstreams/ws-X/attachments",
headers=_AUTH,
)
assert resp.status_code == 200
assert resp.json() == {"attachments": []}
mock_proxy.get.assert_called()
finally:
client.close()
def test_get_content_preserves_upstream_headers(self):
async def _mock(*args: Any, **kwargs: Any) -> httpx.Response:
return httpx.Response(
200,
content=b"hello world",
headers={
"Content-Type": "text/plain; charset=utf-8",
"Content-Disposition": 'inline; filename="notes.md"',
"X-Content-Type-Options": "nosniff",
},
request=httpx.Request("GET", "http://a:8080/x"),
)
app, _ = self._wire(_mock)
client = TestClient(app, raise_server_exceptions=False)
try:
resp = client.get(
"/v1/api/route/workstreams/ws-X/attachments/att-1/content",
headers=_AUTH,
)
assert resp.status_code == 200
assert resp.content == b"hello world"
assert resp.headers.get("X-Content-Type-Options") == "nosniff"
assert "filename" in resp.headers.get("Content-Disposition", "")
finally:
client.close()
def test_delete_proxies_method(self):
captured: dict[str, Any] = {}
async def _mock(*args: Any, **kwargs: Any) -> httpx.Response:
captured["method"] = args[0] if args else ""
captured["url"] = args[1] if len(args) > 1 else ""
return httpx.Response(
200,
json={"status": "deleted"},
request=httpx.Request("DELETE", "http://a:8080/x"),
)
app, _ = self._wire(_mock)
client = TestClient(app, raise_server_exceptions=False)
try:
resp = client.delete(
"/v1/api/route/workstreams/ws-X/attachments/att-1",
headers=_AUTH,
)
assert resp.status_code == 200
assert resp.json() == {"status": "deleted"}
assert captured["method"] == "DELETE"
finally:
client.close()
# ---------------------------------------------------------------------------
# Routing-failure paths
# ---------------------------------------------------------------------------
class TestRoutingFailures:
def test_router_not_ready_returns_503(self):
router = MagicMock(spec=ConsoleRouter)
router.is_ready.return_value = False
router.refresh_cache.return_value = None
app = _make_app(router=router)
app.state.proxy_client = MagicMock(spec=httpx.AsyncClient)
client = TestClient(app, raise_server_exceptions=False)
try:
resp = client.get(
"/v1/api/route/workstreams/ws-X/attachments",
headers=_AUTH,
)
assert resp.status_code == 503
finally:
client.close()
+178 -138
View File
@@ -1,17 +1,13 @@
"""Tests for turnstone.console.router."""
"""Tests for turnstone.console.router (rendezvous routing)."""
from __future__ import annotations
from typing import Any
import secrets
import pytest
from turnstone.console.router import ConsoleRouter, NodeRef
from turnstone.core.hash_ring import RING_SIZE, NoAvailableNodeError
# ---------------------------------------------------------------------------
# Fake storage
# ---------------------------------------------------------------------------
from turnstone.core.rendezvous import NoAvailableNodeError
class FakeStorage:
@@ -19,26 +15,14 @@ class FakeStorage:
def __init__(self) -> None:
self.services: list[dict[str, str]] = []
self.buckets: list[dict[str, Any]] = []
self.overrides: list[dict[str, str]] = []
self.settings: dict[str, dict[str, Any]] = {}
def list_services(self, service_type: str, max_age_seconds: int = 120) -> list[dict[str, str]]:
return list(self.services)
def list_ring_buckets(self) -> list[dict[str, Any]]:
return list(self.buckets)
def list_workstream_overrides(self) -> list[dict[str, str]]:
return list(self.overrides)
def get_system_setting(self, key: str, node_id: str = "") -> dict[str, Any] | None:
return self.settings.get(key)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
NODE_A = {"service_id": "node-a", "url": "http://a:8080", "metadata": "{}"}
NODE_B = {"service_id": "node-b", "url": "http://b:8080", "metadata": "{}"}
@@ -50,206 +34,262 @@ def _make_router(storage: FakeStorage | None = None) -> tuple[ConsoleRouter, Fak
return ConsoleRouter(s), s # type: ignore[arg-type]
def _ws_id_for_bucket(bucket: int) -> str:
"""Build a 32-char hex ws_id whose first 4 chars encode *bucket*."""
return f"{bucket:04x}" + "0" * 28
# ---------------------------------------------------------------------------
# TestRouteBasic
# ---------------------------------------------------------------------------
def _random_ws_id() -> str:
return secrets.token_hex(16)
class TestRouteBasic:
"""Basic routing through the bucket cache."""
def test_route_returns_correct_node(self) -> None:
def test_route_returns_a_live_node(self) -> None:
router, storage = _make_router()
storage.services = [NODE_A, NODE_B, NODE_C]
storage.buckets = [
{"bucket": 0x0000, "node_id": "node-a"},
{"bucket": 0x0001, "node_id": "node-b"},
{"bucket": 0x0002, "node_id": "node-c"},
]
router.refresh_cache()
assert router.route(_ws_id_for_bucket(0x0000)) == NodeRef("node-a", "http://a:8080")
assert router.route(_ws_id_for_bucket(0x0001)) == NodeRef("node-b", "http://b:8080")
assert router.route(_ws_id_for_bucket(0x0002)) == NodeRef("node-c", "http://c:8080")
ref = router.route(_random_ws_id())
assert ref.node_id in {"node-a", "node-b", "node-c"}
def test_route_is_deterministic_for_same_ws_id(self) -> None:
"""Same ws_id + same membership → same target every time."""
router, storage = _make_router()
storage.services = [NODE_A, NODE_B, NODE_C]
router.refresh_cache()
ws_id = _random_ws_id()
first = router.route(ws_id)
for _ in range(50):
assert router.route(ws_id) == first
def test_route_override_priority(self) -> None:
router, storage = _make_router()
storage.services = [NODE_A, NODE_B]
storage.buckets = [{"bucket": 0x0000, "node_id": "node-a"}]
ws_id = _ws_id_for_bucket(0x0000)
ws_id = _random_ws_id()
storage.overrides = [{"ws_id": ws_id, "node_id": "node-b"}]
router.refresh_cache()
# Override wins over bucket assignment
# Override wins regardless of HRW score.
assert router.route(ws_id) == NodeRef("node-b", "http://b:8080")
def test_route_empty_cache_raises(self) -> None:
def test_route_empty_membership_raises(self) -> None:
router, _ = _make_router()
with pytest.raises(NoAvailableNodeError):
router.route(_random_ws_id())
with pytest.raises(NoAvailableNodeError, match="not assigned"):
router.route(_ws_id_for_bucket(0x0000))
def test_route_empty_ws_id_raises(self) -> None:
router, storage = _make_router()
storage.services = [NODE_A]
router.refresh_cache()
with pytest.raises(NoAvailableNodeError, match="empty"):
router.route("")
def test_route_url_convenience(self) -> None:
router, storage = _make_router()
storage.services = [NODE_A]
storage.buckets = [{"bucket": 0x0010, "node_id": "node-a"}]
router.refresh_cache()
assert router.route_url(_random_ws_id()) == "http://a:8080"
class TestMembershipConvergence:
"""Rendezvous gives the minimal-moves property; pin it."""
def test_node_join_only_steals_some_keys(self) -> None:
"""Adding a 4th node moves ~1/4 of keys to it; the other 3
nodes' kept keys are unchanged."""
router, storage = _make_router()
storage.services = [NODE_A, NODE_B, NODE_C]
router.refresh_cache()
assert router.route_url(_ws_id_for_bucket(0x0010)) == "http://a:8080"
sample = [_random_ws_id() for _ in range(2000)]
before = {ws: router.route(ws).node_id for ws in sample}
storage.services = [
NODE_A,
NODE_B,
NODE_C,
{"service_id": "node-d", "url": "http://d:8080", "metadata": "{}"},
]
router.refresh_cache()
after = {ws: router.route(ws).node_id for ws in sample}
# ---------------------------------------------------------------------------
# TestRefreshCache
# ---------------------------------------------------------------------------
moved = sum(1 for ws in sample if before[ws] != after[ws])
moved_to_new = sum(1 for ws in sample if after[ws] == "node-d")
# Every move must be onto the new node — no churn between
# existing nodes.
assert moved == moved_to_new
# Should be roughly 1/4 of keys; allow a wide band for variance.
assert 0.15 < moved / len(sample) < 0.35
class TestRefreshCache:
"""Cache loading from storage."""
def test_refresh_loads_from_storage(self) -> None:
def test_node_leave_only_redistributes_dead_node_keys(self) -> None:
"""Removing node-a sends node-a's keys to b/c only; keys that
were on b/c stay put."""
router, storage = _make_router()
storage.services = [NODE_A]
storage.buckets = [{"bucket": 100, "node_id": "node-a"}]
storage.services = [NODE_A, NODE_B, NODE_C]
router.refresh_cache()
ref = router.route(_ws_id_for_bucket(100))
assert ref.node_id == "node-a"
sample = [_random_ws_id() for _ in range(2000)]
before = {ws: router.route(ws).node_id for ws in sample}
def test_refresh_handles_dead_nodes(self) -> None:
storage.services = [NODE_B, NODE_C]
router.refresh_cache()
after = {ws: router.route(ws).node_id for ws in sample}
for ws in sample:
if before[ws] in ("node-b", "node-c"):
assert after[ws] == before[ws], (
f"key {ws} moved from {before[ws]} to {after[ws]} "
"even though its old owner is still live"
)
else: # was on node-a
assert after[ws] in ("node-b", "node-c")
class TestWeights:
def test_weight_2_node_gets_more_keys_than_weight_1(self) -> None:
router, storage = _make_router()
# node-b is in buckets but not in services (dead/expired)
storage.services = [NODE_A]
storage.buckets = [
{"bucket": 0x0000, "node_id": "node-a"},
{"bucket": 0x0001, "node_id": "node-b"},
storage.services = [
{"service_id": "node-a", "url": "http://a:8080", "metadata": '{"weight": 2}'},
{"service_id": "node-b", "url": "http://b:8080", "metadata": '{"weight": 1}'},
]
router.refresh_cache()
assert router.route(_ws_id_for_bucket(0x0000)).node_id == "node-a"
with pytest.raises(NoAvailableNodeError):
router.route(_ws_id_for_bucket(0x0001))
sample = [_random_ws_id() for _ in range(5000)]
on_a = sum(1 for ws in sample if router.route(ws).node_id == "node-a")
# Heavier node should win clearly more than half; exact ratio
# depends on the simple hash×weight formulation but a/b > 1.4
# for weight 2:1 across 5k samples is reliable.
assert on_a / len(sample) > 0.55
def test_refresh_returns_true_on_change(self) -> None:
def test_invalid_metadata_falls_back_to_weight_1(self) -> None:
router, storage = _make_router()
storage.services = [NODE_A]
storage.buckets = [{"bucket": 0, "node_id": "node-a"}]
assert router.refresh_cache() is True
def test_refresh_returns_false_on_no_change(self) -> None:
router, storage = _make_router()
storage.services = [NODE_A]
storage.buckets = [{"bucket": 0, "node_id": "node-a"}]
storage.services = [
{"service_id": "node-a", "url": "http://a:8080", "metadata": "not json"},
]
router.refresh_cache()
assert router.refresh_cache() is False
# Just confirms it doesn't blow up.
router.route(_random_ws_id())
# ---------------------------------------------------------------------------
# TestCheckVersion
# ---------------------------------------------------------------------------
class TestCheckVersion:
"""Version-gated refresh."""
def test_version_change_triggers_refresh(self) -> None:
class TestRefreshLifecycle:
def test_refresh_cache_publishes_new_membership_immediately(self) -> None:
"""refresh_cache() reloads on the calling thread — the next
route() sees the new membership without any further trigger."""
router, storage = _make_router()
storage.services = [NODE_A]
storage.buckets = [{"bucket": 0, "node_id": "node-a"}]
storage.settings["rebalancer_version"] = {"value": "1"}
router.refresh_cache()
assert router.node_count() == 1
assert router.check_version() is True
assert router.is_ready()
storage.services = [NODE_A, NODE_B]
router.refresh_cache()
assert router.node_count() == 2
def test_concurrent_refresh_returns_false_on_lock_contention(self) -> None:
"""refresh_cache uses a non-blocking lock acquire — if another
thread is already refreshing, the second caller bails so the
in-flight refresh's result is the one that publishes."""
def test_same_version_skips(self) -> None:
router, storage = _make_router()
# Default version is 0; setting absent also means 0
storage.services = [NODE_A]
storage.buckets = [{"bucket": 0, "node_id": "node-a"}]
# First call: version=0 matches self._version=0 -> no refresh
assert router.check_version() is False
assert not router.is_ready() # cache was never loaded
with router._refresh_lock:
# Lock held by this thread → the call below can't acquire.
assert router.refresh_cache() is False
def test_force_refresh_blocks_until_in_flight_refresh_releases(self) -> None:
"""force_refresh acquires the refresh lock blocking — used by the
404-retry path to guarantee a fresh view even under contention."""
import threading
def test_version_none_treated_as_zero(self) -> None:
router, storage = _make_router()
# settings dict is empty -> get_system_setting returns None
assert router.check_version() is False
storage.services = [NODE_A]
# Hold the refresh lock from another thread.
lock_held = threading.Event()
release = threading.Event()
# ---------------------------------------------------------------------------
# TestGenerateWsId
# ---------------------------------------------------------------------------
def hold_lock() -> None:
with router._refresh_lock:
lock_held.set()
release.wait(timeout=2)
holder = threading.Thread(target=hold_lock, daemon=True)
holder.start()
assert lock_held.wait(timeout=1)
# force_refresh should block, not bail.
result_box: list[bool] = []
def call_force() -> None:
result_box.append(router.force_refresh())
caller = threading.Thread(target=call_force, daemon=True)
caller.start()
caller.join(timeout=0.2)
assert caller.is_alive(), "force_refresh returned without acquiring lock"
release.set()
holder.join(timeout=1)
caller.join(timeout=1)
assert not caller.is_alive()
# Membership changed from empty → 1 live node.
assert result_box == [True]
assert router.node_count() == 1
def test_force_refresh_always_reloads(self) -> None:
"""force_refresh skips the non-blocking-lock bail and always
publishes a fresh view back-to-back calls each pick up the
latest storage state."""
router, storage = _make_router()
storage.services = [NODE_A]
router.force_refresh()
assert router.node_count() == 1
storage.services = [NODE_A, NODE_B]
router.force_refresh()
assert router.node_count() == 2
def test_version_is_monotonic_across_refreshes(self) -> None:
router, storage = _make_router()
storage.services = [NODE_A]
router.refresh_cache()
v1 = router.version
router.refresh_cache()
v2 = router.version
assert v2 > v1
router.force_refresh()
assert router.version > v2
class TestGenerateWsId:
"""Workstream ID generation targeting a specific node."""
def test_generates_routable_id(self) -> None:
router, storage = _make_router()
storage.services = [NODE_A, NODE_B]
storage.buckets = [
{"bucket": 0x00FF, "node_id": "node-a"},
{"bucket": 0x0100, "node_id": "node-b"},
]
storage.services = [NODE_A, NODE_B, NODE_C]
router.refresh_cache()
ws_id = router.generate_ws_id_for_node("node-a")
ws_id = router.generate_ws_id_for_node("node-b")
assert len(ws_id) == 32
assert router.route(ws_id).node_id == "node-a"
assert router.route(ws_id).node_id == "node-b"
def test_unknown_node_raises(self) -> None:
router, storage = _make_router()
storage.services = [NODE_A]
storage.buckets = [{"bucket": 0, "node_id": "node-a"}]
router.refresh_cache()
with pytest.raises(NoAvailableNodeError, match="node-z"):
router.generate_ws_id_for_node("node-z")
# ---------------------------------------------------------------------------
# TestIsReady
# ---------------------------------------------------------------------------
class TestIsReady:
"""Readiness checks."""
def test_false_when_empty(self) -> None:
router, _ = _make_router()
assert router.is_ready() is False
def test_true_after_refresh(self) -> None:
def test_true_after_membership_loads(self) -> None:
router, storage = _make_router()
storage.services = [NODE_A]
storage.buckets = [{"bucket": 0, "node_id": "node-a"}]
router.refresh_cache()
assert router.is_ready() is True
# ---------------------------------------------------------------------------
# TestNodeCount
# ---------------------------------------------------------------------------
class TestNodeCount:
"""Distinct node counting."""
def test_count_distinct_nodes(self) -> None:
def test_count_matches_live_services(self) -> None:
router, storage = _make_router()
storage.services = [NODE_A, NODE_B, NODE_C]
# Spread all 65536 buckets across 3 nodes
storage.buckets = [
{"bucket": b, "node_id": f"node-{['a', 'b', 'c'][b % 3]}"} for b in range(RING_SIZE)
]
router.refresh_cache()
assert router.node_count() == 3
+79 -22
View File
@@ -11,7 +11,7 @@ from starlette.testclient import TestClient
from turnstone.console.collector import ClusterCollector
from turnstone.console.router import ConsoleRouter, NodeRef
from turnstone.core.hash_ring import NoAvailableNodeError
from turnstone.core.rendezvous import NoAvailableNodeError
# Shared test auth — JWT-based
_TEST_JWT_SECRET = "test-jwt-secret-minimum-32-chars!"
@@ -93,6 +93,15 @@ def _wire_proxy(app: Any, mock_post: MagicMock | None = None) -> None:
mock_post = _make_proxy_post()
mock_proxy = MagicMock(spec=httpx.AsyncClient)
mock_proxy.post = mock_post
# route_proxy uses ``client.request(method, url, ...)`` for path-keyed
# routes (so DELETE on /send proxies through correctly). Wire a
# request-shim that drops the leading method positional and forwards
# to the same mock_post for compatibility.
async def _request_shim(method: str, *args: Any, **kwargs: Any) -> httpx.Response:
return await mock_post(*args, **kwargs)
mock_proxy.request = MagicMock(side_effect=_request_shim)
app.state.proxy_client = mock_proxy
@@ -102,7 +111,7 @@ def _wire_proxy(app: Any, mock_post: MagicMock | None = None) -> None:
class TestRouteCreate:
"""POST /v1/api/route/workstreams/new — create via hash-ring routing."""
"""POST /v1/api/route/workstreams/new — create via rendezvous routing."""
@pytest.fixture()
def client(self):
@@ -178,6 +187,49 @@ class TestRouteCreate:
router.generate_ws_id_for_node.assert_called_with("node-c")
client.close()
def test_route_create_routing_strategy_rendezvous(self, client):
"""Default fan-out (no resume_ws / no target_node) reports
routing_strategy='rendezvous' so the coordinator's spawn tool
can explain why the node was chosen."""
resp = client.post(
"/v1/api/route/workstreams/new",
json={"name": "test-ws"},
headers=_TEST_AUTH_HEADERS,
)
assert resp.status_code == 200
assert resp.json()["routing_strategy"] == "rendezvous"
def test_route_create_routing_strategy_target_node(self):
router = _make_mock_router()
router.generate_ws_id_for_node.return_value = "00ff" + "0" * 28
router.route.return_value = NodeRef("node-c", "http://c:8080")
app = _make_app(router=router)
_wire_proxy(app, _make_proxy_post(json_data={"ws_id": "00ff" + "0" * 28, "name": "pinned"}))
client = TestClient(app, raise_server_exceptions=False)
resp = client.post(
"/v1/api/route/workstreams/new",
json={"target_node": "node-c"},
headers=_TEST_AUTH_HEADERS,
)
assert resp.status_code == 200
assert resp.json()["routing_strategy"] == "target_node"
client.close()
def test_route_create_routing_strategy_resume(self):
router = _make_mock_router()
router.route.return_value = NodeRef("node-b", "http://b:8080")
app = _make_app(router=router)
_wire_proxy(app, _make_proxy_post(json_data={"ws_id": "old_ws_resumed", "name": "resumed"}))
client = TestClient(app, raise_server_exceptions=False)
resp = client.post(
"/v1/api/route/workstreams/new",
json={"resume_ws": "old_ws_id"},
headers=_TEST_AUTH_HEADERS,
)
assert resp.status_code == 200
assert resp.json()["routing_strategy"] == "resume"
client.close()
class TestRouteCreate503Retry:
"""503 retry logic in route_create."""
@@ -240,7 +292,8 @@ class TestRouteCreate503Retry:
class TestRouteProxy:
"""POST /v1/api/route/send (and other routed endpoints)."""
"""POST /v1/api/route/workstreams/{ws_id}/<verb> (and the surviving
body-keyed plan/command routes)."""
@pytest.fixture()
def client(self):
@@ -253,29 +306,33 @@ class TestRouteProxy:
def test_route_proxy_send(self, client):
resp = client.post(
"/v1/api/route/send",
json={"ws_id": "abc123", "message": "hello"},
"/v1/api/route/workstreams/abc123/send",
json={"message": "hello"},
headers=_TEST_AUTH_HEADERS,
)
assert resp.status_code == 200
# Verify upstream URL was /v1/api/send (not /v1/api/route/send)
mock_post = client.app.state.proxy_client.post
call_args = mock_post.call_args
assert "/v1/api/send" in call_args[0][0]
assert "/route/" not in call_args[0][0]
# Verify upstream URL was /v1/api/workstreams/abc123/send
# (not /v1/api/route/workstreams/abc123/send).
mock_request = client.app.state.proxy_client.request
call_args = mock_request.call_args
# request is called as ``request(method, url, ...)`` — url is the
# second positional arg.
upstream_url = call_args[0][1]
assert "/v1/api/workstreams/abc123/send" in upstream_url
assert "/route/" not in upstream_url
def test_route_proxy_approve(self, client):
resp = client.post(
"/v1/api/route/approve",
json={"ws_id": "abc123", "approved": True},
"/v1/api/route/workstreams/abc123/approve",
json={"approved": True},
headers=_TEST_AUTH_HEADERS,
)
assert resp.status_code == 200
def test_route_proxy_cancel(self, client):
resp = client.post(
"/v1/api/route/cancel",
json={"ws_id": "abc123"},
"/v1/api/route/workstreams/abc123/cancel",
json={},
headers=_TEST_AUTH_HEADERS,
)
assert resp.status_code == 200
@@ -290,8 +347,8 @@ class TestRouteProxy:
def test_route_proxy_close(self, client):
resp = client.post(
"/v1/api/route/workstreams/close",
json={"ws_id": "abc123"},
"/v1/api/route/workstreams/abc123/close",
json={},
headers=_TEST_AUTH_HEADERS,
)
assert resp.status_code == 200
@@ -370,8 +427,8 @@ class TestRouteNotReady:
def test_route_proxy_no_router_503(self, client_no_router):
resp = client_no_router.post(
"/v1/api/route/send",
json={"ws_id": "abc", "message": "hello"},
"/v1/api/route/workstreams/abc/send",
json={"message": "hello"},
headers=_TEST_AUTH_HEADERS,
)
assert resp.status_code == 503
@@ -382,8 +439,8 @@ class TestRouteNotReady:
def test_route_proxy_empty_cache_503(self, client_empty_cache):
resp = client_empty_cache.post(
"/v1/api/route/send",
json={"ws_id": "abc", "message": "hello"},
"/v1/api/route/workstreams/abc/send",
json={"message": "hello"},
headers=_TEST_AUTH_HEADERS,
)
assert resp.status_code == 503
@@ -422,8 +479,8 @@ class TestRouteNoNode:
def test_route_proxy_no_node_503(self, client):
resp = client.post(
"/v1/api/route/send",
json={"ws_id": "abc", "message": "hello"},
"/v1/api/route/workstreams/abc/send",
json={"message": "hello"},
headers=_TEST_AUTH_HEADERS,
)
assert resp.status_code == 503
+599
View File
@@ -0,0 +1,599 @@
"""Tests for the rich ``ws_state`` payload on coord (Stage 2 follow-up).
Pre-lift coord's ``ConsoleCoordinatorUI`` populated none of the per-ws
metric fields ``SessionUIBase`` defines (``_ws_prompt_tokens`` /
``_ws_context_ratio`` / ``_ws_current_activity`` / ``_ws_turn_content``)
and the ``coord_adapter.emit_state`` broadcast was state-only
``tokens=0`` / ``content=""`` were hardcoded into
``collector.emit_console_ws_state``. The lift turned ``on_status`` /
``on_content_token`` / ``on_thinking_*`` / ``on_tool_result`` into
shared bodies on :class:`SessionUIBase` so coord populates the same
fields, then enriched ``coord_adapter.emit_state`` to read them under
lock and pass through to the cluster collector with the rich kwargs.
The cluster dashboard's coord rows now render with the same
tokens / activity / content / context_ratio fields interactive rows do.
"""
from __future__ import annotations
import threading
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from turnstone.console.coordinator_ui import ConsoleCoordinatorUI
from turnstone.core.session_ui_base import _MAX_TURN_CONTENT_CHARS
from turnstone.core.workstream import WorkstreamState
# ---------------------------------------------------------------------------
# Per-ws metric writes — lifted to SessionUIBase, both subclasses inherit
# ---------------------------------------------------------------------------
def _patch_get_storage(storage: Any):
return patch("turnstone.core.storage._registry.get_storage", return_value=storage)
def test_coord_on_status_writes_per_ws_metrics() -> None:
"""Pre-lift coord ``on_status`` was an enqueue-only stub — ``_ws_*``
fields stayed at their initial zero values regardless of token usage.
Post-lift coord inherits SessionUIBase's body, so token counters and
context ratio populate just like interactive."""
ui = ConsoleCoordinatorUI(ws_id="coord-ws", user_id="u1")
with _patch_get_storage(MagicMock()):
ui.on_status(
{"prompt_tokens": 100, "completion_tokens": 50},
context_window=1000,
effort="medium",
)
assert ui._ws_prompt_tokens == 100
assert ui._ws_completion_tokens == 50
assert ui._ws_context_ratio == pytest.approx(0.15)
def test_coord_on_status_persists_usage_event() -> None:
"""Pre-lift coord didn't persist usage_event rows — only WebUI did.
Lift extends usage tracking to coord so governance dashboards see
coordinator token consumption alongside interactive."""
storage = MagicMock()
ui = ConsoleCoordinatorUI(ws_id="coord-ws", user_id="u1")
with _patch_get_storage(storage):
ui.on_status(
{"prompt_tokens": 7, "completion_tokens": 3, "model": "gpt-x"},
context_window=200,
effort="low",
)
storage.record_usage_event.assert_called_once()
kwargs = storage.record_usage_event.call_args.kwargs
assert kwargs["ws_id"] == "coord-ws"
assert kwargs["user_id"] == "u1"
assert kwargs["model"] == "gpt-x"
assert kwargs["prompt_tokens"] == 7
assert kwargs["completion_tokens"] == 3
def test_coord_on_content_token_accumulates() -> None:
"""Pre-lift coord ``on_content_token`` only enqueued; lift turns it
into the same per-ws accumulator WebUI uses so the collector
broadcast can piggyback the joined turn content on the IDLE
state-change event."""
ui = ConsoleCoordinatorUI(ws_id="coord-ws", user_id="u1")
ui.on_content_token("Hello ")
ui.on_content_token("world")
assert ui._ws_turn_content == ["Hello ", "world"]
assert ui._ws_turn_content_size == len("Hello world")
def test_coord_on_content_token_caps_at_ceiling() -> None:
"""Same content cap interactive enforces — keeps a runaway turn from
ballooning the cluster broadcast event past listener queue size."""
ui = ConsoleCoordinatorUI(ws_id="coord-ws", user_id="u1")
chunk = "x" * 1024
rounds = (_MAX_TURN_CONTENT_CHARS // 1024) + 50
for _ in range(rounds):
ui.on_content_token(chunk)
# Cap is enforced at the size check; one over-cap chunk still
# gets in (per the original ``< _MAX``-not-``<=`` semantics) but
# nothing past that lands.
assert ui._ws_turn_content_size <= _MAX_TURN_CONTENT_CHARS + 1024
def test_coord_on_thinking_start_sets_activity() -> None:
"""Live activity tracking — coord's dashboard row now flips
``activity_state`` to ``"thinking"`` when the model starts."""
ui = ConsoleCoordinatorUI(ws_id="coord-ws", user_id="u1")
ui.on_thinking_start()
assert ui._ws_current_activity == "Thinking…"
assert ui._ws_activity_state == "thinking"
def test_coord_on_tool_result_clears_activity_and_increments_counters() -> None:
"""Lifted ``on_tool_result`` body increments ``_ws_tool_calls`` /
``_ws_turn_tool_calls`` and clears the activity. Pre-lift coord
just enqueued without touching counters."""
ui = ConsoleCoordinatorUI(ws_id="coord-ws", user_id="u1")
ui._ws_current_activity = "⚙ bash: ls -la"
ui._ws_activity_state = "tool"
ui.on_tool_result("call-1", "bash", "output")
assert ui._ws_tool_calls == {"bash": 1}
assert ui._ws_turn_tool_calls == 1
assert ui._ws_current_activity == ""
assert ui._ws_activity_state == ""
# ---------------------------------------------------------------------------
# Snapshot helper — drains turn content on IDLE/ERROR
# ---------------------------------------------------------------------------
def test_snapshot_idle_returns_content_and_clears_accumulator() -> None:
"""IDLE snapshot piggybacks the joined assistant content onto the
state-change broadcast (so the dashboard renders the turn without
a storage round-trip), then clears the accumulator for the next
turn."""
ui = ConsoleCoordinatorUI(ws_id="coord-ws", user_id="u1")
ui.on_content_token("Here's ")
ui.on_content_token("the result.")
payload = ui.snapshot_and_consume_state_payload("idle")
assert payload["content"] == "Here's the result."
assert ui._ws_turn_content == []
assert ui._ws_turn_content_size == 0
def test_snapshot_error_clears_accumulator_without_emitting_content() -> None:
"""ERROR clears the partial content (the turn's broken; nothing to
render) but the broadcast itself doesn't carry it — the state
transition is what matters."""
ui = ConsoleCoordinatorUI(ws_id="coord-ws", user_id="u1")
ui.on_content_token("partial...")
payload = ui.snapshot_and_consume_state_payload("error")
assert payload["content"] == ""
assert ui._ws_turn_content == []
def test_snapshot_thinking_does_not_touch_accumulator() -> None:
"""Mid-turn state transitions (running / thinking / attention)
don't drain the accumulator — only IDLE / ERROR are terminal."""
ui = ConsoleCoordinatorUI(ws_id="coord-ws", user_id="u1")
ui.on_content_token("partial mid-turn")
payload = ui.snapshot_and_consume_state_payload("thinking")
assert payload["content"] == ""
# Accumulator preserved.
assert ui._ws_turn_content == ["partial mid-turn"]
def test_snapshot_carries_token_and_activity_snapshot() -> None:
"""Snapshot reads tokens / context_ratio / activity under one lock
acquisition so concurrent on_status / on_thinking_start writes
don't tear the snapshot."""
ui = ConsoleCoordinatorUI(ws_id="coord-ws", user_id="u1")
with _patch_get_storage(MagicMock()):
ui.on_status(
{"prompt_tokens": 80, "completion_tokens": 20},
context_window=400,
effort="medium",
)
ui.on_thinking_start() # sets activity = "Thinking…"
payload = ui.snapshot_and_consume_state_payload("running")
assert payload["tokens"] == 100
assert payload["context_ratio"] == pytest.approx(0.25)
assert payload["activity"] == "Thinking…"
assert payload["activity_state"] == "thinking"
# ---------------------------------------------------------------------------
# Coord adapter — passes rich payload to collector
# ---------------------------------------------------------------------------
class _FakeCollectorRecorder:
"""Captures emit_console_ws_state calls so we can assert on the
rich kwargs the lifted coord_adapter.emit_state passes through."""
def __init__(self) -> None:
self.state_calls: list[dict[str, Any]] = []
self.activity_calls: list[dict[str, Any]] = []
def emit_console_ws_state(
self,
ws_id: str,
state: str,
*,
tokens: int = 0,
context_ratio: float = 0.0,
activity: str = "",
activity_state: str = "",
content: str = "",
) -> None:
self.state_calls.append(
{
"ws_id": ws_id,
"state": state,
"tokens": tokens,
"context_ratio": context_ratio,
"activity": activity,
"activity_state": activity_state,
"content": content,
}
)
def update_console_ws_activity(self, ws_id: str, *, activity: str, activity_state: str) -> None:
self.activity_calls.append(
{"ws_id": ws_id, "activity": activity, "activity_state": activity_state}
)
def emit_console_ws_created(self, *_a: Any, **_kw: Any) -> None:
pass
def emit_console_ws_closed(self, *_a: Any, **_kw: Any) -> None:
pass
def emit_console_ws_rename(self, *_a: Any, **_kw: Any) -> None:
pass
def ensure_console_pseudo_node(self) -> None:
pass
def _build_adapter_and_ws(ws_id: str = "coord-ws-1") -> tuple[Any, Any, _FakeCollectorRecorder]:
"""Construct a minimal adapter + Workstream + UI for emit_state tests.
Skips the full SessionManager wire-up the adapter's ``emit_state``
only reads ``ws.id`` and ``ws.ui``, so a real ``Workstream`` with
a populated ``ConsoleCoordinatorUI`` is enough.
"""
from turnstone.console.coordinator_adapter import CoordinatorAdapter
from turnstone.core.workstream import Workstream
recorder = _FakeCollectorRecorder()
adapter = CoordinatorAdapter(
collector=recorder, # type: ignore[arg-type]
ui_factory=lambda ws: ConsoleCoordinatorUI(ws_id=ws.id, user_id=ws.user_id),
session_factory=lambda ws: MagicMock(),
)
ws = Workstream(id=ws_id, user_id="u1", name="my-coord")
ws.ui = ConsoleCoordinatorUI(ws_id=ws_id, user_id="u1")
return adapter, ws, recorder
def test_coord_adapter_emit_state_passes_rich_payload_to_collector() -> None:
"""Pre-lift coord_adapter.emit_state called collector with state-only;
post-lift it reads the UI's per-ws snapshot under lock and passes
tokens / context_ratio / activity / content kwargs through."""
adapter, ws, recorder = _build_adapter_and_ws()
with _patch_get_storage(MagicMock()):
ws.ui.on_status(
{"prompt_tokens": 60, "completion_tokens": 40},
context_window=400,
effort="medium",
)
ws.ui.on_content_token("partial answer")
ws.ui.on_thinking_start()
adapter.emit_state(ws, WorkstreamState.RUNNING)
assert len(recorder.state_calls) == 1
call = recorder.state_calls[0]
assert call["ws_id"] == ws.id
assert call["state"] == "running"
assert call["tokens"] == 100
assert call["context_ratio"] == pytest.approx(0.25)
assert call["activity"] == "Thinking…"
assert call["activity_state"] == "thinking"
# Mid-turn (RUNNING) — content stays accumulated for the eventual IDLE drain.
assert call["content"] == ""
def test_coord_adapter_emit_state_idle_drains_content() -> None:
"""IDLE state-change drains the turn-content accumulator and
piggybacks the joined content on the broadcast same shape WebUI
uses on global_queue. Subsequent emit_state must see the
accumulator cleared."""
adapter, ws, recorder = _build_adapter_and_ws()
ws.ui.on_content_token("Here's ")
ws.ui.on_content_token("the result.")
adapter.emit_state(ws, WorkstreamState.IDLE)
assert len(recorder.state_calls) == 1
assert recorder.state_calls[0]["content"] == "Here's the result."
# Accumulator drained — next emit_state sees nothing carried over.
adapter.emit_state(ws, WorkstreamState.IDLE)
assert recorder.state_calls[1]["content"] == ""
def test_coord_adapter_emit_state_handles_missing_ui_defensively() -> None:
"""``ws.ui`` can be ``None`` mid-eviction; emit_state still
broadcasts the state-change with empty rich fields so the
dashboard's coord row still flips state instead of going stale."""
adapter, ws, recorder = _build_adapter_and_ws()
ws.ui = None # simulate teardown race
adapter.emit_state(ws, WorkstreamState.RUNNING)
assert len(recorder.state_calls) == 1
call = recorder.state_calls[0]
assert call["state"] == "running"
assert call["tokens"] == 0
assert call["content"] == ""
# ---------------------------------------------------------------------------
# Coord activity broadcast — UI fans out directly to the collector
# ---------------------------------------------------------------------------
def test_coord_ui_broadcast_activity_calls_collector() -> None:
"""Live activity transitions on coord (between state changes) reach
the cluster collector via the new ``update_console_ws_activity``
method. WebUI's analog goes via the global SSE queue; coord's
UI calls the collector directly since the console isn't a node."""
recorder = _FakeCollectorRecorder()
ui = ConsoleCoordinatorUI(ws_id="coord-ws", user_id="u1")
ConsoleCoordinatorUI._collector = recorder # type: ignore[assignment]
try:
ui.on_thinking_start() # base impl calls _broadcast_activity
assert len(recorder.activity_calls) == 1
call = recorder.activity_calls[0]
assert call["ws_id"] == "coord-ws"
assert call["activity"] == "Thinking…"
assert call["activity_state"] == "thinking"
finally:
ConsoleCoordinatorUI._collector = None
def test_coord_ui_broadcast_activity_swallows_collector_failure() -> None:
"""A flaky collector must NOT block the worker thread — activity
fan-out is observational, the worker keeps running on collector
failure."""
recorder = MagicMock()
recorder.update_console_ws_activity.side_effect = RuntimeError("collector dead")
ui = ConsoleCoordinatorUI(ws_id="coord-ws", user_id="u1")
ConsoleCoordinatorUI._collector = recorder
try:
ui.on_thinking_start() # must not raise
recorder.update_console_ws_activity.assert_called_once()
finally:
ConsoleCoordinatorUI._collector = None
def test_coord_ui_broadcast_activity_no_op_when_collector_unset() -> None:
"""Tests / tooling that don't wire a collector shouldn't crash."""
ui = ConsoleCoordinatorUI(ws_id="coord-ws", user_id="u1")
ConsoleCoordinatorUI._collector = None
ui.on_thinking_start() # must not raise
def test_coord_ui_broadcast_activity_failure_does_not_strand_dedup() -> None:
"""Regression for the Copilot finding on PR #420: post-fix the
dedup state ``_last_broadcast_activity`` is updated **only after**
a successful collector call. If the collector raises mid-broadcast
on tick #1, tick #2 with the same activity tuple must still
attempt the broadcast (otherwise a transient collector failure
would strand the dashboard's coord row at the pre-failure
activity until the activity actually changes). Pre-fix the
dedup state was assigned inside the lock before the collector
call, so the failed broadcast still updated it and tick #2
silently no-op'd."""
recorder = MagicMock()
# First call fails (transient collector outage); second call succeeds.
recorder.update_console_ws_activity.side_effect = [
RuntimeError("collector dead"),
None,
]
ui = ConsoleCoordinatorUI(ws_id="coord-ws", user_id="u1")
ConsoleCoordinatorUI._collector = recorder
try:
# Tick #1 — collector raises; dedup state must NOT update.
ui.on_thinking_start()
assert ui._last_broadcast_activity is None, (
"dedup state was updated despite a failed collector call — "
"next identical tick would be silently suppressed"
)
# Tick #2 — same activity tuple. Pre-fix this would no-op
# (because dedup state was already (Thinking…, thinking)).
# Post-fix it retries; collector succeeds; dedup state lands.
ui.on_thinking_start()
assert recorder.update_console_ws_activity.call_count == 2, (
"second tick was deduped despite the first call failing"
)
assert ui._last_broadcast_activity == ("Thinking…", "thinking")
finally:
ConsoleCoordinatorUI._collector = None
def test_coord_ui_broadcast_activity_dedup_skips_identical_after_success() -> None:
"""Happy-path dedup: after a successful broadcast, the next identical
tick is deduped the cluster collector lock is not re-acquired
for a no-op write. This is the perf optimization the dedup is
there for; the regression test above checks the failure-recovery
invariant doesn't break it."""
recorder = MagicMock()
ui = ConsoleCoordinatorUI(ws_id="coord-ws", user_id="u1")
ConsoleCoordinatorUI._collector = recorder
try:
ui.on_thinking_start() # tick 1 — fires
ui.on_thinking_start() # tick 2 — same tuple, deduped
ui.on_thinking_start() # tick 3 — same tuple, deduped
assert recorder.update_console_ws_activity.call_count == 1
assert ui._last_broadcast_activity == ("Thinking…", "thinking")
finally:
ConsoleCoordinatorUI._collector = None
# ---------------------------------------------------------------------------
# Spawn metrics — coord wires its own hook
# ---------------------------------------------------------------------------
def test_coord_spawn_metrics_increments_messages_and_resets_tool_count() -> None:
"""Coord's ``_coord_spawn_metrics`` mirrors interactive's per-spawn
counter writes (sans the Prometheus call) so the rich ``ws_state``
broadcast renders the same per-turn shape."""
from turnstone.console.server import _coord_spawn_metrics
ui = ConsoleCoordinatorUI(ws_id="coord-ws", user_id="u1")
ui._ws_messages = 5
ui._ws_turn_tool_calls = 3
_coord_spawn_metrics(MagicMock(), ui)
assert ui._ws_messages == 6
assert ui._ws_turn_tool_calls == 0
def test_coord_spawn_metrics_tolerates_ui_without_counters() -> None:
"""A SessionUI subclass without the per-ws counters shouldn't trip
the hook defensive guard mirrors the interactive analog."""
from turnstone.console.server import _coord_spawn_metrics
class _StubUI:
pass
_coord_spawn_metrics(MagicMock(), _StubUI()) # must not raise
# ---------------------------------------------------------------------------
# Snapshot lock — single-acquisition guarantee
# ---------------------------------------------------------------------------
def test_snapshot_acquires_ws_lock_exactly_once() -> None:
"""Snapshot must read all four fields under a single lock acquisition
so concurrent on_status / on_thinking_start writes can't tear the
payload."""
ui = ConsoleCoordinatorUI(ws_id="coord-ws", user_id="u1")
acquire_count = 0
inner = ui._ws_lock
class _CountingLock:
def __enter__(self) -> None:
nonlocal acquire_count
acquire_count += 1
inner.acquire()
def __exit__(self, *a: Any) -> None:
inner.release()
def acquire(self, *a: Any, **kw: Any) -> bool:
return inner.acquire(*a, **kw)
def release(self) -> None:
inner.release()
ui._ws_lock = _CountingLock() # type: ignore[assignment]
ui.snapshot_and_consume_state_payload("idle")
assert acquire_count == 1, (
f"snapshot acquired _ws_lock {acquire_count} times; concurrent "
"writes could tear the rich payload"
)
# ---------------------------------------------------------------------------
# Concurrency — snapshot under load
# ---------------------------------------------------------------------------
def test_snapshot_under_concurrent_writes_does_not_crash() -> None:
"""Sanity stress: snapshot reads while on_status / on_thinking_start /
on_content_token write concurrently. Reader cycles through
``("running", "idle", "error")`` so the IDLE/ERROR drain branches
that mutate ``_ws_turn_content`` actually get exercised against
concurrent appends running-only would only hit the read-only
snapshot path. Each thread's exception (if any) is captured + raised
on join so a silent worker crash can't slip through as a bare
deadlock-check pass."""
ui = ConsoleCoordinatorUI(ws_id="coord-ws", user_id="u1")
writer_exc: list[Exception] = []
reader_exc: list[Exception] = []
def _writer() -> None:
try:
with _patch_get_storage(MagicMock()):
for i in range(50):
ui.on_status(
{"prompt_tokens": i, "completion_tokens": i},
context_window=1000,
effort="low",
)
ui.on_content_token(f"chunk-{i}")
ui.on_thinking_start()
except Exception as exc: # noqa: BLE001 — surface to main thread
writer_exc.append(exc)
def _reader() -> None:
try:
states = ("running", "idle", "error")
for i in range(50):
ui.snapshot_and_consume_state_payload(states[i % len(states)])
except Exception as exc: # noqa: BLE001 — surface to main thread
reader_exc.append(exc)
writer = threading.Thread(target=_writer)
reader = threading.Thread(target=_reader)
writer.start()
reader.start()
writer.join(timeout=5)
reader.join(timeout=5)
assert not writer.is_alive(), "writer thread deadlocked"
assert not reader.is_alive(), "reader thread deadlocked"
assert not writer_exc, f"writer raised: {writer_exc[0]!r}"
assert not reader_exc, f"reader raised: {reader_exc[0]!r}"
def test_coord_on_stream_end_clears_activity() -> None:
"""Lifted ``on_stream_end`` body clears ``_ws_current_activity``
and ``_ws_activity_state`` so the dashboard's coord row stops
showing the stale 'Thinking…' indicator after the stream
finishes. Pre-lift coord just enqueued ``stream_end`` without
touching activity this test pins the new clear path so a
future re-stub doesn't silently re-introduce a stuck activity
indicator."""
ui = ConsoleCoordinatorUI(ws_id="coord-ws", user_id="u1")
ui._ws_current_activity = "Thinking…"
ui._ws_activity_state = "thinking"
ui.on_stream_end()
assert ui._ws_current_activity == ""
assert ui._ws_activity_state == ""
# ---------------------------------------------------------------------------
# WebUI override semantics still preserved
# ---------------------------------------------------------------------------
def test_webui_on_status_still_records_prometheus_metrics() -> None:
"""The lift moves the per-ws writes to SessionUIBase but WebUI's
override must still fire ``_metrics.record_*`` (Prometheus on the
node /metrics endpoint). Regression guard against a future refactor
accidentally dropping the override."""
import queue
from turnstone.server import WebUI
WebUI._global_queue = queue.Queue()
try:
ui = WebUI(ws_id="ws-int", user_id="u1")
with patch("turnstone.server._metrics") as mock_metrics, _patch_get_storage(MagicMock()):
ui.on_status(
{"prompt_tokens": 10, "completion_tokens": 5},
context_window=200,
effort="low",
)
mock_metrics.record_tokens.assert_called_once_with(10, 5)
mock_metrics.record_cache_tokens.assert_called_once()
mock_metrics.record_context_ratio.assert_called_once()
finally:
WebUI._global_queue = None
def test_webui_on_tool_result_still_records_prometheus_tool_call() -> None:
"""Same as above for ``on_tool_result``."""
import queue
from turnstone.server import WebUI
WebUI._global_queue = queue.Queue()
try:
ui = WebUI(ws_id="ws-int", user_id="u1")
with patch("turnstone.server._metrics") as mock_metrics:
ui.on_tool_result("call-1", "bash", "output")
mock_metrics.record_tool_call.assert_called_once_with("bash")
# Per-ws counter writes happened too (inherited from base).
assert ui._ws_tool_calls == {"bash": 1}
assert ui._ws_turn_tool_calls == 1
finally:
WebUI._global_queue = None
+466
View File
@@ -0,0 +1,466 @@
"""Tests for the unified ``approve_tools`` body, viewed from the coord side.
The body itself is exercised by ``test_webui_auto_approve_visibility``;
this file pins down the coord-specific contracts that lifting the body
to ``SessionUIBase`` automatically enables:
- Tool-policy gating now applies to coord tool calls (was interactive-only).
- Heuristic verdicts persist on coord (was interactive-only).
- The activity tag fields populate on coord during pending approval.
- ``judge_pending`` is dynamic on the coord ``approve_request``
(was hardcoded ``False``).
- The auto-approve fall-through emits ``tool_info`` (was
``tools_auto_approved``).
- ``_record_judge_metric`` is a no-op on coord (no Prometheus on console).
"""
from __future__ import annotations
import threading
from typing import Any
from unittest.mock import MagicMock, patch
from turnstone.console.coordinator_ui import ConsoleCoordinatorUI
def _make_items(*specs: tuple[str, str], needs_approval: bool = True) -> list[dict[str, Any]]:
return [
{
"call_id": call_id,
"header": f"Tool: {func}",
"preview": "preview text",
"func_name": func,
"approval_label": func,
"needs_approval": needs_approval,
}
for call_id, func in specs
]
def _patch_storage(storage: Any):
return patch("turnstone.core.storage._registry.get_storage", return_value=storage)
def _patch_policies(verdicts: dict[str, str]):
return patch(
"turnstone.core.policy.evaluate_tool_policies_batch",
return_value=verdicts,
)
# ---------------------------------------------------------------------------
# Inheritance regression — the unification itself
# ---------------------------------------------------------------------------
def test_coord_inherits_approve_tools_from_base() -> None:
"""``ConsoleCoordinatorUI`` must NOT define its own ``approve_tools``;
the shared body lives on :class:`SessionUIBase`. A future drift
adding a coord-only override is exactly the kind of bug this
unification is meant to prevent, so guard it explicitly."""
assert "approve_tools" not in ConsoleCoordinatorUI.__dict__, (
"ConsoleCoordinatorUI shouldn't redefine approve_tools — "
"the shared body on SessionUIBase covers both kinds."
)
assert ConsoleCoordinatorUI.approve_tools.__qualname__ == "SessionUIBase.approve_tools"
# ---------------------------------------------------------------------------
# Tool-policy gating now applies to coord
# ---------------------------------------------------------------------------
def test_coord_tool_policy_deny_blocks_coord_tool() -> None:
"""Admin-defined ``deny`` policies now fire on coord tool calls.
Pre-lift this was interactive-only; an admin who wanted to block
e.g. ``delete_workstream`` on the coord couldn't."""
ui = ConsoleCoordinatorUI(ws_id="coord-1", user_id="u1")
items = _make_items(("c1", "delete_workstream"))
storage = MagicMock()
with _patch_storage(storage), _patch_policies({"delete_workstream": "deny"}):
approved, err = ui.approve_tools(items)
assert approved is False
assert err == "Blocked by tool policy"
assert items[0].get("denied") is True
def test_coord_tool_policy_allow_tags_with_policy_source() -> None:
"""Admin ``allow`` rule auto-approves the item with
``AutoApproveReason.POLICY``. This was a no-op on coord pre-lift."""
ui = ConsoleCoordinatorUI(ws_id="coord-1", user_id="u1")
items = _make_items(("c1", "spawn_workstream"))
storage = MagicMock()
with _patch_storage(storage), _patch_policies({"spawn_workstream": "allow"}):
approved, _err = ui.approve_tools(items)
assert approved is True
snapshot = ui.serialize_recent_auto_approvals()
assert len(snapshot) == 1
assert snapshot[0]["func_name"] == "spawn_workstream"
assert snapshot[0]["auto_approve_reason"] == "policy"
def test_coord_tool_policy_mixed_allow_deny_records_allowed_sibling() -> None:
"""Same ``mixed-policy`` audit-leak fix that
``test_webui_auto_approve_visibility`` validates for interactive,
now auto-applies to coord via the lifted body."""
ui = ConsoleCoordinatorUI(ws_id="coord-1", user_id="u1")
items = _make_items(("c1", "delete_workstream"), ("c2", "list_workstreams"))
storage = MagicMock()
with (
_patch_storage(storage),
_patch_policies({"delete_workstream": "deny", "list_workstreams": "allow"}),
):
approved, _err = ui.approve_tools(items)
assert approved is False
snapshot = ui.serialize_recent_auto_approvals()
assert len(snapshot) == 1
assert snapshot[0]["func_name"] == "list_workstreams"
assert snapshot[0]["auto_approve_reason"] == "policy"
# ---------------------------------------------------------------------------
# Heuristic-verdict persistence + metric hook
# ---------------------------------------------------------------------------
def test_coord_heuristic_verdict_persists_to_storage() -> None:
"""Heuristic verdicts attached to items now flow through to
``storage.create_intent_verdicts_bulk`` on coord. Pre-lift coord
silently dropped them; only LLM-tier verdicts (from the daemon
judge thread via ``on_intent_verdict``) reached storage. Post
perf-2 the path uses bulk INSERT so a fan-out turn pays one commit
instead of N."""
ui = ConsoleCoordinatorUI(ws_id="coord-1", user_id="u1")
hv = {
"verdict_id": "v1",
"call_id": "c1",
"func_name": "spawn_workstream",
"tier": "heuristic",
"risk_level": "high",
"confidence": 0.75,
"recommendation": "review",
"reasoning": "spawning child with bash skill",
"evidence": ["bash"],
"latency_ms": 12,
}
items = _make_items(("c1", "spawn_workstream"))
items[0]["_heuristic_verdict"] = hv
storage = MagicMock()
timer = threading.Timer(0.05, lambda: ui.resolve_approval(False))
timer.start()
try:
with _patch_storage(storage):
ui.approve_tools(items)
finally:
timer.cancel()
storage.create_intent_verdicts_bulk.assert_called_once()
rows = storage.create_intent_verdicts_bulk.call_args.args[0]
assert len(rows) == 1
assert rows[0]["verdict_id"] == "v1"
assert rows[0]["tier"] == "heuristic"
assert rows[0]["ws_id"] == "coord-1"
def test_coord_record_judge_metric_fires_console_metrics() -> None:
"""``_record_judge_metric`` increments the console's
``ConsoleMetrics`` judge counter when the class attribute is wired,
so coord verdicts surface on the console's /metrics endpoint
alongside the per-node series."""
from turnstone.console.metrics import ConsoleMetrics
cm = ConsoleMetrics()
ui = ConsoleCoordinatorUI(ws_id="coord-1", user_id="u1")
try:
ConsoleCoordinatorUI._console_metrics = cm
ui._record_judge_metric({"tier": "heuristic", "risk_level": "high", "latency_ms": 12})
finally:
ConsoleCoordinatorUI._console_metrics = None
text = cm.generate_text()
assert 'turnstone_judge_verdicts_total{tier="heuristic",risk_level="high"} 1' in text
def test_coord_record_judge_metric_safe_when_unwired() -> None:
"""No /metrics instance set → silent no-op. Test fixtures that
don't spin up a full console app must not crash on judge
verdicts during the shared ``approve_tools`` body."""
ui = ConsoleCoordinatorUI(ws_id="coord-1", user_id="u1")
# Sanity: class attribute is None at module import time outside
# the lifespan — exactly the test-fixture state.
assert ConsoleCoordinatorUI._console_metrics is None
# Should not raise.
ui._record_judge_metric({"tier": "heuristic", "risk_level": "low"})
def test_coord_on_intent_verdict_fires_metric_for_llm_tier() -> None:
"""Async LLM verdicts from the daemon judge thread land at
``on_intent_verdict``. Coord overrides it to fire the same
``record_judge_verdict`` call WebUI does different tier label,
same cluster-wide histogram."""
from turnstone.console.metrics import ConsoleMetrics
cm = ConsoleMetrics()
ui = ConsoleCoordinatorUI(ws_id="coord-1", user_id="u1")
try:
ConsoleCoordinatorUI._console_metrics = cm
with _patch_storage(MagicMock()):
ui.on_intent_verdict(
{
"verdict_id": "v1",
"call_id": "c1",
"tier": "llm",
"risk_level": "medium",
"latency_ms": 250,
}
)
finally:
ConsoleCoordinatorUI._console_metrics = None
text = cm.generate_text()
assert 'turnstone_judge_verdicts_total{tier="llm",risk_level="medium"} 1' in text
# ---------------------------------------------------------------------------
# Activity tagging during pending approval
# ---------------------------------------------------------------------------
def test_coord_pending_approval_sets_activity_tag() -> None:
"""The shared body tags ``_ws_current_activity`` /
``_ws_activity_state`` so the cluster collector's coord-row
snapshot reflects the approval wait. Pre-lift coord left these
fields empty during pending approval."""
ui = ConsoleCoordinatorUI(ws_id="coord-1", user_id="u1")
items = _make_items(("c1", "delete_workstream"))
captured: dict[str, str] = {}
def _capture_activity() -> None:
captured["activity"] = ui._ws_current_activity
captured["state"] = ui._ws_activity_state
ui.resolve_approval(False)
timer = threading.Timer(0.05, _capture_activity)
timer.start()
try:
with _patch_storage(MagicMock()):
ui.approve_tools(items)
finally:
timer.cancel()
assert "Awaiting approval" in captured["activity"]
assert "delete_workstream" in captured["activity"]
assert captured["state"] == "approval"
def test_coord_auto_approve_sets_tool_activity_tag() -> None:
"""Blanket auto-approve flips activity to the ``⚙ {tool}: {preview}``
shape WebUI has used; coord row now mirrors it."""
ui = ConsoleCoordinatorUI(ws_id="coord-1", user_id="u1")
ui.auto_approve = True
items = _make_items(("c1", "spawn_workstream"))
with _patch_storage(MagicMock()):
approved, _err = ui.approve_tools(items)
assert approved is True
assert "spawn_workstream" in ui._ws_current_activity
assert ui._ws_activity_state == "tool"
# ---------------------------------------------------------------------------
# judge_pending flag + event-name parity
# ---------------------------------------------------------------------------
def test_coord_judge_pending_flag_dynamic_when_heuristic_present() -> None:
"""Pre-lift coord hardcoded ``judge_pending=False`` on every
``approve_request``; the unified body computes the bool from the
items, matching WebUI."""
ui = ConsoleCoordinatorUI(ws_id="coord-1", user_id="u1")
items = _make_items(("c1", "spawn_workstream"))
items[0]["_heuristic_verdict"] = {"verdict_id": "v1", "tier": "heuristic"}
captured_events: list[dict[str, Any]] = []
ui._enqueue = captured_events.append # type: ignore[method-assign]
timer = threading.Timer(0.05, lambda: ui.resolve_approval(False))
timer.start()
try:
with _patch_storage(MagicMock()):
ui.approve_tools(items)
finally:
timer.cancel()
approve_requests = [e for e in captured_events if e.get("type") == "approve_request"]
assert len(approve_requests) == 1
assert approve_requests[0]["judge_pending"] is True
def test_coord_blanket_auto_approve_emits_tool_info() -> None:
"""Event-name parity: the auto-approve fall-through emits
``tool_info`` for both kinds. Pre-lift coord emitted
``tools_auto_approved`` the rename happens implicitly via
inheritance."""
ui = ConsoleCoordinatorUI(ws_id="coord-1", user_id="u1")
ui.auto_approve = True
items = _make_items(("c1", "spawn_workstream"))
captured_events: list[dict[str, Any]] = []
ui._enqueue = captured_events.append # type: ignore[method-assign]
with _patch_storage(MagicMock()):
ui.approve_tools(items)
types = [e.get("type") for e in captured_events]
assert "tool_info" in types
assert "tools_auto_approved" not in types
def test_coord_judge_pending_false_when_no_heuristic_verdict() -> None:
"""Counterpart to ``test_coord_judge_pending_flag_dynamic_when_heuristic_present``:
items with no ``_heuristic_verdict`` produce ``approve_request`` with
``judge_pending=False``. Without this case pinned, a regression that
hardcodes ``judge_pending=True`` (the inverse of the pre-lift coord
bug) would slip through unnoticed."""
ui = ConsoleCoordinatorUI(ws_id="coord-1", user_id="u1")
items = _make_items(("c1", "spawn_workstream"))
# Deliberately no _heuristic_verdict on any item.
captured_events: list[dict[str, Any]] = []
ui._enqueue = captured_events.append # type: ignore[method-assign]
timer = threading.Timer(0.05, lambda: ui.resolve_approval(False))
timer.start()
try:
with _patch_storage(MagicMock()):
ui.approve_tools(items)
finally:
timer.cancel()
approve_requests = [e for e in captured_events if e.get("type") == "approve_request"]
assert len(approve_requests) == 1
assert approve_requests[0]["judge_pending"] is False
# ---------------------------------------------------------------------------
# Per-tool auto-approve via auto_approve_tools (set membership)
# ---------------------------------------------------------------------------
def test_coord_per_tool_auto_approve_tags_with_source() -> None:
"""When a coord tool name lands in ``auto_approve_tools`` (e.g. via a
skill template's ``allowed_tools``), the lifted body short-circuits
the prompt and tags the item with ``AutoApproveReason.AUTO_APPROVE_TOOLS``
(or the per-tool source from ``_auto_approve_tools_source``).
Mirrors the WebUI test ``test_auto_approve_tools_skill_source_renders_as_skill``
on the coord side so the unified body gains parity coverage."""
ui = ConsoleCoordinatorUI(ws_id="coord-1", user_id="u1")
ui.auto_approve_tools = {"spawn_workstream"}
ui._auto_approve_tools_source = {"spawn_workstream": "skill"}
items = _make_items(("c1", "spawn_workstream"))
storage = MagicMock()
with _patch_storage(storage):
approved, _err = ui.approve_tools(items)
assert approved is True
snapshot = ui.serialize_recent_auto_approvals()
assert len(snapshot) == 1
assert snapshot[0]["func_name"] == "spawn_workstream"
assert snapshot[0]["auto_approve_reason"] == "skill"
# ---------------------------------------------------------------------------
# __budget_override__ carve-out — sec-2 hardening
# ---------------------------------------------------------------------------
def test_coord_budget_override_prompts_even_under_blanket_auto_approve() -> None:
"""The carve-out promises ``__budget_override__`` always prompts the
operator. Pin that behavior on the coord side so a future regression
of the post-filter / pre-filter check (sec-2) gets caught.
``__budget_override__`` is interactive-only today (coord workstreams
don't have token budgets), but the synthetic item can be threaded
through ``approve_tools`` directly the same way ``ChatSession.send``
does on the interactive side. The carve-out fires uniformly across
both kinds."""
ui = ConsoleCoordinatorUI(ws_id="coord-1", user_id="u1")
ui.auto_approve = True # blanket flag — should NOT bypass the carve-out
items = [
{
"call_id": "c1",
"header": "Token budget exhausted",
"preview": "Token budget (200,000) exhausted. Approve to continue.",
"func_name": "__budget_override__",
"approval_label": "__budget_override__",
"needs_approval": True,
}
]
captured_events: list[dict[str, Any]] = []
ui._enqueue = captured_events.append # type: ignore[method-assign]
timer = threading.Timer(0.05, lambda: ui.resolve_approval(True))
timer.start()
try:
with _patch_storage(MagicMock()):
approved, _err = ui.approve_tools(items)
finally:
timer.cancel()
assert approved is True
# The carve-out forces the prompt path, NOT the auto-approve fall-through.
types = [e.get("type") for e in captured_events]
assert "approve_request" in types, (
"Budget override must produce an approve_request even under blanket auto_approve"
)
assert "tool_info" not in types, (
"Auto-approve fall-through must not fire when a budget override is present"
)
def test_coord_budget_override_survives_wildcard_allow_policy() -> None:
"""A wildcard ``*: allow`` policy must not strip ``__budget_override__``
from the gate. Pre-sec-2, the policy block could mark the item
``needs_approval=False`` and remove it from ``pending``, after which
the carve-out (which read ``pending``) would see no override and
blanket auto-approve would silently fire. Post-fix the carve-out
reads from the pre-filter ``items`` list AND the policy block skips
matching the synthetic name entirely."""
ui = ConsoleCoordinatorUI(ws_id="coord-1", user_id="u1")
ui.auto_approve = True
items = [
{
"call_id": "c1",
"header": "Token budget exhausted",
"preview": "Token budget exhausted. Approve to continue.",
"func_name": "__budget_override__",
"approval_label": "__budget_override__",
"needs_approval": True,
}
]
captured_events: list[dict[str, Any]] = []
ui._enqueue = captured_events.append # type: ignore[method-assign]
timer = threading.Timer(0.05, lambda: ui.resolve_approval(True))
timer.start()
try:
with _patch_storage(MagicMock()), _patch_policies({"__budget_override__": "allow"}):
approved, _err = ui.approve_tools(items)
finally:
timer.cancel()
assert approved is True
types = [e.get("type") for e in captured_events]
assert "approve_request" in types, "Wildcard allow must not strip the budget-override prompt"
+560
View File
@@ -0,0 +1,560 @@
"""Tests for CoordinatorAdapter.
Mirrors test_interactive_adapter.py: focuses on the transport contract
(what gets sent to the ClusterCollector) and cleanup_ui behavior
(unblock listener queues, cancel session). The SessionManager-level
tests in test_session_manager.py cover the lifecycle path.
"""
from __future__ import annotations
import queue
import threading
from typing import Any
from unittest.mock import MagicMock
from turnstone.console.coordinator_adapter import CoordinatorAdapter
from turnstone.core.workstream import Workstream, WorkstreamKind, WorkstreamState
class _StubCoordUI:
"""Stub matching the subset of ConsoleCoordinatorUI the adapter touches."""
def __init__(self) -> None:
self._approval_event = threading.Event()
self._approval_result: tuple[bool, str | None] = (True, "initial")
self._plan_event = threading.Event()
self._plan_result: str = "accept"
self._fg_event = threading.Event()
self._listeners_lock = threading.Lock()
self._listeners: list[queue.Queue[dict[str, Any]]] = []
class _StubSession:
def __init__(self) -> None:
self.cancelled = False
self.closed = False
def cancel(self) -> None:
self.cancelled = True
def close(self) -> None:
self.closed = True
def _make_adapter(
collector: Any = None,
*,
ui_factory: Any = None,
session_factory: Any = None,
) -> tuple[CoordinatorAdapter, MagicMock]:
collector = collector or MagicMock()
adapter = CoordinatorAdapter(
collector=collector,
ui_factory=ui_factory or (lambda ws: _StubCoordUI()),
session_factory=session_factory or (lambda *a, **kw: _StubSession()),
)
return adapter, collector
def _make_ws(**overrides: Any) -> Workstream:
ws = Workstream(id="coord-1", name="my-coord")
ws.kind = WorkstreamKind.COORDINATOR
ws.user_id = "u1"
ws.ui = _StubCoordUI()
ws.session = _StubSession()
for k, v in overrides.items():
setattr(ws, k, v)
return ws
# ---------------------------------------------------------------------------
# Transport — emit_created / emit_state / emit_closed
# ---------------------------------------------------------------------------
def test_emit_created_calls_collector_with_coord_fields() -> None:
adapter, collector = _make_adapter()
ws = _make_ws()
adapter.emit_created(ws)
collector.emit_console_ws_created.assert_called_once_with(
"coord-1",
name="my-coord",
user_id="u1",
kind=WorkstreamKind.COORDINATOR.value,
state=WorkstreamState.IDLE.value,
parent_ws_id=None,
)
def test_emit_state_calls_collector_state() -> None:
"""Post-rich-payload, emit_state passes tokens / context_ratio /
activity / activity_state / content kwargs read from ws.ui's
snapshot. Default values (zeros / empty strings) when the UI
hasn't recorded any per-ws metrics yet."""
adapter, collector = _make_adapter()
ws = _make_ws()
adapter.emit_state(ws, WorkstreamState.RUNNING)
collector.emit_console_ws_state.assert_called_once_with(
"coord-1",
WorkstreamState.RUNNING.value,
tokens=0,
context_ratio=0.0,
activity="",
activity_state="",
content="",
)
def test_emit_closed_calls_collector_closed() -> None:
adapter, collector = _make_adapter()
adapter.emit_closed("coord-1")
collector.emit_console_ws_closed.assert_called_once_with("coord-1")
def test_emit_closed_swallows_reason_kwarg() -> None:
"""The console collector doesn't propagate a 'reason' — the console
frontend's evicted special-case only fires for real-node
workstreams. Protocol compatibility only."""
adapter, collector = _make_adapter()
adapter.emit_closed("coord-1", reason="evicted")
collector.emit_console_ws_closed.assert_called_once_with("coord-1")
def test_emit_tolerates_collector_exception() -> None:
collector = MagicMock()
collector.emit_console_ws_created.side_effect = RuntimeError("collector dead")
collector.emit_console_ws_state.side_effect = RuntimeError("collector dead")
collector.emit_console_ws_closed.side_effect = RuntimeError("collector dead")
adapter, _ = _make_adapter(collector=collector)
ws = _make_ws()
# All three must swallow — the session lifecycle must not break
# because the collector had a transient failure.
adapter.emit_created(ws)
adapter.emit_state(ws, WorkstreamState.RUNNING)
adapter.emit_closed("coord-1")
# ---------------------------------------------------------------------------
# cleanup_ui
# ---------------------------------------------------------------------------
def test_cleanup_ui_unblocks_events_and_broadcasts_to_listeners() -> None:
adapter, _ = _make_adapter()
ws = _make_ws()
ws.ui._approval_event.clear() # type: ignore[attr-defined]
ws.ui._plan_event.clear() # type: ignore[attr-defined]
ws.ui._fg_event.clear() # type: ignore[attr-defined]
lq: queue.Queue[dict[str, Any]] = queue.Queue(maxsize=5)
ws.ui._listeners.append(lq) # type: ignore[attr-defined]
adapter.cleanup_ui(ws)
assert ws.ui._approval_event.is_set() # type: ignore[attr-defined]
assert ws.ui._plan_event.is_set() # type: ignore[attr-defined]
assert ws.ui._fg_event.is_set() # type: ignore[attr-defined]
assert ws.ui._approval_result == (False, None) # type: ignore[attr-defined]
assert ws.ui._plan_result == "reject" # type: ignore[attr-defined]
assert lq.get_nowait() == {"type": "ws_closed"}
assert ws.ui._listeners == [] # type: ignore[attr-defined]
assert ws.session.cancelled is True # type: ignore[attr-defined]
assert ws.session.closed is True # type: ignore[attr-defined]
def test_cleanup_ui_listener_full_queue_evicts_head() -> None:
adapter, _ = _make_adapter()
ws = _make_ws()
lq: queue.Queue[dict[str, Any]] = queue.Queue(maxsize=1)
lq.put_nowait({"type": "stale"})
ws.ui._listeners.append(lq) # type: ignore[attr-defined]
adapter.cleanup_ui(ws)
assert lq.get_nowait() == {"type": "ws_closed"}
def test_cleanup_ui_tolerates_missing_session_and_ui() -> None:
adapter, _ = _make_adapter()
ws = _make_ws()
ws.session = None
ws.ui = None
adapter.cleanup_ui(ws) # no crash
# ---------------------------------------------------------------------------
# Construction passthrough
# ---------------------------------------------------------------------------
def test_build_session_forwards_skill_model_kind_parent() -> None:
captured: dict[str, Any] = {}
def _sf(ui: Any, model: str | None, ws_id: str, **kwargs: Any) -> Any:
captured["ui"] = ui
captured["model"] = model
captured["ws_id"] = ws_id
captured.update(kwargs)
return _StubSession()
adapter, _ = _make_adapter(session_factory=_sf)
ws = _make_ws()
ws.parent_ws_id = None
adapter.build_session(ws, skill="coordinator", model="gpt-5")
assert captured["ui"] is ws.ui
assert captured["model"] == "gpt-5"
assert captured["skill"] == "coordinator"
assert captured["kind"] == WorkstreamKind.COORDINATOR
assert captured["parent_ws_id"] is None
# client_type intentionally NOT forwarded — coord session_factory
# doesn't accept it (fixed as 'console').
assert "client_type" not in captured
def test_build_ui_delegates_to_ui_factory() -> None:
captured_ws: list[Workstream] = []
def _ui_factory(ws: Workstream) -> Any:
captured_ws.append(ws)
return _StubCoordUI()
adapter, _ = _make_adapter(ui_factory=_ui_factory)
ws = _make_ws()
result = adapter.build_ui(ws)
assert captured_ws == [ws]
assert isinstance(result, _StubCoordUI)
# ---------------------------------------------------------------------------
# Worker dispatch — _spawn_worker / send
# ---------------------------------------------------------------------------
class _SendSession:
"""ChatSession stub with send / queue_message accounting."""
def __init__(
self,
*,
queue_full: bool = False,
send_gate: threading.Event | None = None,
) -> None:
self.send_calls: list[str] = []
self.queue_calls: list[str] = []
self._queue_full = queue_full
# When set, ``send`` blocks on this event — lets the test pin a
# worker inside session.send while a second thread races through
# _spawn_worker, proving the lock gate (not Thread.is_alive) is
# what serialises them.
self._send_gate = send_gate
self._send_lock = threading.Lock()
self.cancelled = False
self.closed = False
def send(
self,
message: str,
attachments: Any = None,
send_id: str | None = None,
) -> None:
if self._send_gate is not None:
self._send_gate.wait(timeout=2.0)
with self._send_lock:
self.send_calls.append(message)
def queue_message(
self,
message: str,
attachment_ids: Any = None,
queue_msg_id: str | None = None,
) -> None:
if self._queue_full:
raise queue.Full
self.queue_calls.append(message)
def cancel(self) -> None:
self.cancelled = True
def close(self) -> None:
self.closed = True
class _StubManager:
"""Minimal SessionManager stub exposing ``get`` for adapter.send."""
def __init__(self, ws: Workstream | None = None) -> None:
self._ws = ws
def get(self, ws_id: str) -> Workstream | None:
if self._ws is not None and self._ws.id == ws_id:
return self._ws
return None
class TestCoordinatorAdapterWorkerDispatch:
def test_spawn_worker_reuses_when_worker_running(self) -> None:
adapter, _ = _make_adapter()
ws = _make_ws()
session = _SendSession()
ws.session = session # type: ignore[assignment]
ws._worker_running = True # pre-existing worker
adapter.attach(_StubManager(ws)) # type: ignore[arg-type]
assert adapter.send(ws.id, "hello") is True
assert session.queue_calls == ["hello"]
assert session.send_calls == []
# worker_thread not replaced
assert ws.worker_thread is None
def test_spawn_worker_returns_false_on_queue_full(self) -> None:
adapter, _ = _make_adapter()
ws = _make_ws()
session = _SendSession(queue_full=True)
ws.session = session # type: ignore[assignment]
ws._worker_running = True
adapter.attach(_StubManager(ws)) # type: ignore[arg-type]
assert adapter.send(ws.id, "hello") is False
assert session.send_calls == []
def test_spawn_worker_concurrent_calls_produce_one_worker(self) -> None:
"""Bug-1 reproducer: two simultaneous send() calls under ws._lock
must land as exactly one ChatSession.send and one queued message,
not two parallel workers on the same ChatSession."""
adapter, _ = _make_adapter()
ws = _make_ws()
send_gate = threading.Event()
session = _SendSession(send_gate=send_gate)
ws.session = session # type: ignore[assignment]
adapter.attach(_StubManager(ws)) # type: ignore[arg-type]
results: list[bool] = []
start_barrier = threading.Barrier(2)
results_lock = threading.Lock()
def _caller(msg: str) -> None:
start_barrier.wait(timeout=1.0)
r = adapter.send(ws.id, msg)
with results_lock:
results.append(r)
t1 = threading.Thread(target=_caller, args=("first",))
t2 = threading.Thread(target=_caller, args=("second",))
t1.start()
t2.start()
# Both callers return quickly: the winner spawns the worker
# (returns True immediately) and the loser queues (returns True).
t1.join(timeout=3.0)
t2.join(timeout=3.0)
assert not t1.is_alive() and not t2.is_alive()
# At this point session.send is still blocked on send_gate —
# the second caller MUST have taken the queue path.
assert len(session.queue_calls) == 1
# Release the worker and let it finish.
send_gate.set()
if ws.worker_thread is not None:
ws.worker_thread.join(timeout=3.0)
assert results == [True, True]
assert len(session.send_calls) == 1
assert set(session.send_calls + session.queue_calls) == {"first", "second"}
assert ws._worker_running is False
def test_worker_finally_clears_running_flag(self) -> None:
adapter, _ = _make_adapter()
ws = _make_ws()
session = _SendSession()
ws.session = session # type: ignore[assignment]
adapter.attach(_StubManager(ws)) # type: ignore[arg-type]
assert adapter.send(ws.id, "hello") is True
assert ws.worker_thread is not None
ws.worker_thread.join(timeout=2.0)
assert ws._worker_running is False
assert session.send_calls == ["hello"]
# ---------------------------------------------------------------------------
# Children registry
# ---------------------------------------------------------------------------
class TestCoordinatorAdapterChildrenRegistry:
def test_emit_created_seeds_empty_children_set(self) -> None:
adapter, _ = _make_adapter()
ws = _make_ws()
adapter.emit_created(ws)
assert ws.id in adapter._children
assert adapter._children[ws.id] == set()
assert adapter._active_coords[ws.id] is ws.ui
def test_emit_rehydrated_calls_rebuild(self) -> None:
adapter, _ = _make_adapter()
calls: list[str] = []
# Monkeypatch the rebuild hook to count invocations without
# requiring a real storage backend.
adapter._rebuild_children_registry = calls.append # type: ignore[method-assign, assignment]
ws = _make_ws()
adapter.emit_created(ws)
assert calls == []
adapter.emit_rehydrated(ws)
assert calls == [ws.id]
def test_emit_closed_clears_forward_and_reverse_indexes(self) -> None:
adapter, _ = _make_adapter()
with adapter._children_lock:
adapter._merge_child_ids_locked("coord-a", ["child-a1", "child-a2"])
adapter._merge_child_ids_locked("coord-b", ["child-b1"])
adapter._active_coords["coord-a"] = object()
adapter._active_coords["coord-b"] = object()
adapter.emit_closed("coord-a")
assert "coord-a" not in adapter._children
assert "coord-a" not in adapter._active_coords
assert "child-a1" not in adapter._child_to_coord
assert "child-a2" not in adapter._child_to_coord
# coord-b untouched
assert adapter._child_to_coord["child-b1"] == "coord-b"
assert "coord-b" in adapter._children
def test_merge_child_ids_locked_is_idempotent(self) -> None:
adapter, _ = _make_adapter()
with adapter._children_lock:
adapter._merge_child_ids_locked("coord-a", ["child-1"])
adapter._merge_child_ids_locked("coord-a", ["child-1"])
assert adapter._children["coord-a"] == {"child-1"}
assert adapter._child_to_coord == {"child-1": "coord-a"}
def test_prime_children_from_snapshot_merges_without_overwriting(self) -> None:
adapter, _ = _make_adapter()
# Seed one in-memory coord + one existing child
coord_ws = _make_ws()
coord_ws.id = "coord-a"
mgr = MagicMock()
mgr.list_all.return_value = [coord_ws]
adapter.attach(mgr)
with adapter._children_lock:
adapter._merge_child_ids_locked("coord-a", ["child-a1"])
snapshot = {
"nodes": [
{
"workstreams": [
{"id": "child-a2", "parent_ws_id": "coord-a"},
# Unknown parent — skipped
{"id": "child-x", "parent_ws_id": "coord-unknown"},
# Missing fields — skipped
{"id": "", "parent_ws_id": "coord-a"},
],
},
],
}
adapter._prime_children_from_snapshot(snapshot)
assert adapter._children["coord-a"] == {"child-a1", "child-a2"}
assert adapter._child_to_coord["child-a2"] == "coord-a"
assert "child-x" not in adapter._child_to_coord
# ---------------------------------------------------------------------------
# Dispatch — _dispatch_child_event
# ---------------------------------------------------------------------------
class _UIRecorder:
"""UI stub capturing _enqueue payloads for dispatch assertions."""
def __init__(self) -> None:
self.enqueued: list[dict[str, Any]] = []
def _enqueue(self, payload: dict[str, Any]) -> None:
self.enqueued.append(payload)
class TestCoordinatorAdapterDispatchChildEvent:
def _setup(
self, coord_id: str = "coord-a"
) -> tuple[CoordinatorAdapter, _UIRecorder, Workstream]:
adapter, _ = _make_adapter()
coord_ws = _make_ws()
coord_ws.id = coord_id
recorder = _UIRecorder()
coord_ws.ui = recorder # type: ignore[assignment]
with adapter._children_lock:
adapter._children.setdefault(coord_id, set())
adapter._active_coords[coord_id] = recorder
adapter.attach(_StubManager(coord_ws)) # type: ignore[arg-type]
return adapter, recorder, coord_ws
def test_dispatch_unknown_parent_drops_event(self) -> None:
adapter, recorder, _ = self._setup()
adapter._dispatch_child_event(
{"type": "ws_created", "ws_id": "orphan", "parent_ws_id": "coord-unknown"}
)
adapter._dispatch_child_event({"type": "cluster_state", "ws_id": "orphan"})
adapter._dispatch_child_event({"type": "ws_closed", "ws_id": "orphan"})
assert recorder.enqueued == []
def test_dispatch_ws_created_routes_to_parent_coord_ui(self) -> None:
adapter, recorder, _ = self._setup()
adapter._dispatch_child_event(
{
"type": "ws_created",
"ws_id": "child-a1",
"parent_ws_id": "coord-a",
"name": "kid",
"node_id": "node-1",
}
)
assert len(recorder.enqueued) == 1
payload = recorder.enqueued[0]
assert payload["type"] == "child_ws_created"
assert payload["child_ws_id"] == "child-a1"
assert payload["parent_ws_id"] == "coord-a"
# Reverse index updated for subsequent cluster_state events.
assert adapter._child_to_coord["child-a1"] == "coord-a"
def test_dispatch_cluster_state_routes_via_reverse_index(self) -> None:
adapter, recorder, _ = self._setup()
with adapter._children_lock:
adapter._merge_child_ids_locked("coord-a", ["child-a1"])
adapter._dispatch_child_event(
{
"type": "cluster_state",
"ws_id": "child-a1",
"state": "running",
"tokens": 42,
"node_id": "node-1",
}
)
assert len(recorder.enqueued) == 1
payload = recorder.enqueued[0]
assert payload["type"] == "child_ws_state"
assert payload["state"] == "running"
assert payload["tokens"] == 42
def test_dispatch_ws_closed_routes_to_parent_coord(self) -> None:
adapter, recorder, _ = self._setup()
with adapter._children_lock:
adapter._merge_child_ids_locked("coord-a", ["child-a1"])
adapter._dispatch_child_event(
{"type": "ws_closed", "ws_id": "child-a1", "reason": "evicted"}
)
assert len(recorder.enqueued) == 1
payload = recorder.enqueued[0]
assert payload["type"] == "child_ws_closed"
assert payload["reason"] == "evicted"
assert payload["parent_ws_id"] == "coord-a"
def test_dispatch_adds_ws_id_in_place(self) -> None:
"""perf-6: _enqueue_on_ui mutates the payload dict in place with
the coord's ws_id so the browser can discriminate child events."""
adapter, recorder, _ = self._setup()
with adapter._children_lock:
adapter._merge_child_ids_locked("coord-a", ["child-a1"])
adapter._dispatch_child_event(
{
"type": "cluster_state",
"ws_id": "child-a1",
"state": "running",
}
)
assert recorder.enqueued[0]["ws_id"] == "coord-a"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,236 @@
"""Tests for the coordinator ``close_all_children`` endpoint.
Near-twin of the ``stop_cascade`` tests in
``test_coordinator_governance.py``. Keeps the close-cascade surface in
its own file so PR A's review surface stays tight.
"""
from __future__ import annotations
import json
from unittest.mock import MagicMock
import pytest
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.routing import Route
from starlette.testclient import TestClient
from tests._coord_test_helpers import (
_AuthMiddleware,
_build_mgr,
_fake_registry,
_FakeConfigStore,
_seed_children,
)
from turnstone.console.server import coordinator_close_all_children
from turnstone.core.storage._sqlite import SQLiteBackend
@pytest.fixture
def storage(tmp_path):
return SQLiteBackend(str(tmp_path / "coord.db"))
_COORD_HEADERS = {"X-Test-User": "user-1", "X-Test-Perms": "admin.coordinator"}
def _make_client(storage, *, coord_mgr, alias="my-model", registry=None) -> TestClient:
app = Starlette(
routes=[
Route(
"/v1/api/workstreams/{ws_id}/close_all_children",
coordinator_close_all_children,
methods=["POST"],
),
],
middleware=[Middleware(_AuthMiddleware)],
)
app.state.coord_mgr = coord_mgr
app.state.coord_adapter = coord_mgr._adapter if coord_mgr is not None else None
app.state.config_store = _FakeConfigStore({"coordinator.model_alias": alias})
app.state.coord_registry = registry
app.state.coord_registry_error = "" if coord_mgr else "registry missing"
app.state.auth_storage = storage
app.state.jwt_secret = "x" * 64
return TestClient(app)
def test_close_all_children_closes_each_child_and_audits(storage):
mgr = _build_mgr(storage)
coord = mgr.create(user_id="user-1", name="coord-a")
_seed_children(mgr._adapter, coord.id, ["child-1", "child-2", "child-3"])
def _close(wid, reason):
if wid == "child-2":
return {"error": "gateway_timeout", "status": 502}
return {"status": "ok"}
coord_client = MagicMock()
coord_client.close_workstream.side_effect = _close
coord.session = MagicMock()
coord.session._coord_client = coord_client
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
f"/v1/api/workstreams/{coord.id}/close_all_children",
json={"reason": "tests done"},
headers=_COORD_HEADERS,
)
assert resp.status_code == 200
body = resp.json()
assert set(body["closed"] + body["failed"] + body["skipped"]) == {
"child-1",
"child-2",
"child-3",
}
assert body["failed"] == ["child-2"]
assert set(body["closed"]) == {"child-1", "child-3"}
assert body["skipped"] == []
assert coord_client.close_workstream.call_count == 3
# Reason must propagate to each per-child close call.
for call in coord_client.close_workstream.call_args_list:
assert call.args[1] == "tests done"
events = [
e for e in storage.list_audit_events() if e["action"] == "coordinator.closed_all_children"
]
assert len(events) == 1
detail = json.loads(events[0]["detail"])
assert detail["reason"] == "tests done"
assert set(detail["closed"] + detail["failed"] + detail["skipped"]) == {
"child-1",
"child-2",
"child-3",
}
def test_close_all_children_routes_404_to_skipped_bucket(storage):
"""An upstream 404 (child row already deleted, stale registry entry)
is 'already gone', not a dispatch failure. Route to skipped."""
mgr = _build_mgr(storage)
coord = mgr.create(user_id="user-1", name="coord-a")
_seed_children(mgr._adapter, coord.id, ["stale-child"])
coord_client = MagicMock()
coord_client.close_workstream.return_value = {
"error": "workstream not in coordinator subtree: stale-child",
"status": 404,
}
coord.session = MagicMock()
coord.session._coord_client = coord_client
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
f"/v1/api/workstreams/{coord.id}/close_all_children",
json={},
headers=_COORD_HEADERS,
)
assert resp.status_code == 200
body = resp.json()
assert body["closed"] == []
assert body["failed"] == []
assert body["skipped"] == ["stale-child"]
def test_close_all_children_empty_children_still_audits(storage):
mgr = _build_mgr(storage)
coord = mgr.create(user_id="user-1", name="coord-a")
coord.session = MagicMock()
coord.session._coord_client = MagicMock()
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
f"/v1/api/workstreams/{coord.id}/close_all_children",
json={},
headers=_COORD_HEADERS,
)
assert resp.status_code == 200
body = resp.json()
assert body == {"status": "ok", "closed": [], "failed": [], "skipped": []}
assert [
e for e in storage.list_audit_events() if e["action"] == "coordinator.closed_all_children"
]
def test_close_all_children_without_coord_client_marks_all_failed(storage):
mgr = _build_mgr(storage)
coord = mgr.create(user_id="user-1", name="coord-a")
_seed_children(mgr._adapter, coord.id, ["child-a", "child-b"])
coord.session = MagicMock()
coord.session._coord_client = None
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
f"/v1/api/workstreams/{coord.id}/close_all_children",
json={},
headers=_COORD_HEADERS,
)
assert resp.status_code == 200
body = resp.json()
assert body["closed"] == []
assert body["skipped"] == []
assert set(body["failed"]) == {"child-a", "child-b"}
def test_close_all_children_rejects_non_string_reason(storage):
mgr = _build_mgr(storage)
coord = mgr.create(user_id="user-1", name="coord-a")
coord.session = MagicMock()
coord.session._coord_client = MagicMock()
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
f"/v1/api/workstreams/{coord.id}/close_all_children",
json={"reason": 123},
headers=_COORD_HEADERS,
)
assert resp.status_code == 400
def test_close_all_children_rejects_overlong_reason(storage):
mgr = _build_mgr(storage)
coord = mgr.create(user_id="user-1", name="coord-a")
coord.session = MagicMock()
coord.session._coord_client = MagicMock()
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
f"/v1/api/workstreams/{coord.id}/close_all_children",
json={"reason": "x" * 600},
headers=_COORD_HEADERS,
)
assert resp.status_code == 400
def test_close_all_children_404_when_session_not_loaded(storage):
mgr = _build_mgr(storage)
coord = mgr.create(user_id="user-1", name="coord-a")
coord.session = None
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
f"/v1/api/workstreams/{coord.id}/close_all_children",
json={},
headers=_COORD_HEADERS,
)
assert resp.status_code == 404
def test_close_all_children_service_token_cannot_bypass_admin_coordinator(storage):
"""Destructive endpoint — a service token matching the coord owner
still needs the explicit ``admin.coordinator`` grant. Mirrors the
stop_cascade treatment."""
mgr = _build_mgr(storage)
coord = mgr.create(user_id="user-1", name="coord-a")
coord.session = MagicMock()
coord.session._coord_client = MagicMock()
# Service token without admin.coordinator should be rejected.
headers = {"X-Test-User": "user-1", "X-Test-Perms": ""}
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
f"/v1/api/workstreams/{coord.id}/close_all_children",
json={},
headers=headers,
)
assert resp.status_code in (401, 403)
+484
View File
@@ -0,0 +1,484 @@
"""End-to-end integration tests for the coordinator workstream feature.
Tests cover the full create inspect list close lifecycle using
real in-process components:
1. Create + list + detail round-trip via the Starlette TestClient.
2. CoordinatorClient against a MockTransport "server node" stub.
3. list_children storage read flow (kind filtering, parent scoping).
4. Lazy rehydration via GET /v1/api/workstreams/{ws_id}.
Intentionally no real LLM infrastructure session factories return
MagicMock-backed stubs. All four tests run in < 2 s total.
"""
from __future__ import annotations
import json
from typing import Any
from unittest.mock import MagicMock
import httpx
import pytest
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.routing import Route
from starlette.testclient import TestClient
from turnstone.console.collector import ClusterCollector
from turnstone.console.coordinator_adapter import CoordinatorAdapter
from turnstone.console.coordinator_client import CoordinatorClient
from turnstone.console.coordinator_ui import ConsoleCoordinatorUI
from turnstone.console.server import (
_audit_close_coordinator,
_audit_coordinator_create,
_coord_create_build_kwargs,
_coord_create_post_install,
_coord_create_validate_request,
_require_admin_coordinator,
_require_coord_mgr,
)
from turnstone.core.auth import AuthResult
from turnstone.core.session_manager import SessionManager
from turnstone.core.session_routes import (
SessionEndpointConfig,
make_close_handler,
make_create_handler,
make_detail_handler,
make_list_handler,
)
from turnstone.core.storage._sqlite import SQLiteBackend
# Per-kind config the lifted handler factories capture by closure.
_coord_endpoint_config = SessionEndpointConfig(
permission_gate=_require_admin_coordinator,
manager_lookup=_require_coord_mgr,
tenant_check=None,
not_found_label="coordinator not found",
audit_action_prefix="coordinator",
create_supports_attachments=True,
create_supports_user_id_override=False,
create_validate_request=_coord_create_validate_request,
create_build_kwargs=_coord_create_build_kwargs,
create_post_install=_coord_create_post_install,
)
# ---------------------------------------------------------------------------
# Shared auth-injection middleware (mirrors test_coordinator_endpoints.py)
# ---------------------------------------------------------------------------
class _AuthMiddleware(BaseHTTPMiddleware):
"""Inject an ``AuthResult`` from ``X-Test-Perms`` / ``X-Test-User``."""
async def dispatch(self, request, call_next):
perms = request.headers.get("X-Test-Perms", "")
user_id = request.headers.get("X-Test-User", "")
if perms or user_id:
request.state.auth_result = AuthResult(
user_id=user_id,
scopes=frozenset({"approve"}),
token_source="test",
permissions=frozenset(p for p in perms.split(",") if p),
)
return await call_next(request)
# ---------------------------------------------------------------------------
# Shared stubs
# ---------------------------------------------------------------------------
class _FakeConfigStore:
"""Minimal ConfigStore stub returning values from a dict."""
def __init__(self, values: dict[str, Any]) -> None:
self._values = values
def get(self, key: str, default: Any = None) -> Any:
return self._values.get(key, default)
def _fake_registry() -> MagicMock:
"""Registry stub that always succeeds on .resolve() so the 503 gate passes."""
reg = MagicMock()
reg.resolve.return_value = (MagicMock(), "gpt-test", MagicMock())
return reg
def _build_mgr(storage: SQLiteBackend) -> SessionManager:
"""Build a SessionManager(CoordinatorAdapter) backed by stub factories."""
def _sf(ui, model_alias=None, ws_id=None, **kw):
s = MagicMock()
s.ws_id = ws_id
s.send.return_value = None
return s
adapter = CoordinatorAdapter(
collector=MagicMock(),
ui_factory=lambda ws: ConsoleCoordinatorUI(ws_id=ws.id, user_id=ws.user_id or ""),
session_factory=_sf,
)
mgr = SessionManager(
adapter,
storage=storage,
max_active=5,
node_id=ClusterCollector.CONSOLE_PSEUDO_NODE_ID,
event_emitter=adapter,
)
adapter.attach(mgr)
return mgr
def _make_client(
storage: SQLiteBackend,
*,
coord_mgr: SessionManager | None = None,
alias: str = "my-model",
registry: Any = None,
) -> TestClient:
"""Build a Starlette TestClient exposing the coordinator routes."""
app = Starlette(
routes=[
Route(
"/v1/api/workstreams/new",
make_create_handler(_coord_endpoint_config, audit_emit=_audit_coordinator_create),
methods=["POST"],
),
Route(
"/v1/api/workstreams",
make_list_handler(_coord_endpoint_config),
methods=["GET"],
),
Route(
"/v1/api/workstreams/{ws_id}/close",
make_close_handler(
_coord_endpoint_config,
audit_emit=_audit_close_coordinator,
supports_close_reason=False,
),
methods=["POST"],
),
Route(
"/v1/api/workstreams/{ws_id}",
make_detail_handler(_coord_endpoint_config),
methods=["GET"],
),
],
middleware=[Middleware(_AuthMiddleware)],
)
app.state.coord_mgr = coord_mgr
app.state.coord_adapter = coord_mgr._adapter if coord_mgr is not None else None
app.state.config_store = _FakeConfigStore({"coordinator.model_alias": alias})
app.state.coord_registry = registry
app.state.coord_registry_error = "" if coord_mgr else "registry missing"
app.state.auth_storage = storage
app.state.jwt_secret = "x" * 64
return TestClient(app)
# ---------------------------------------------------------------------------
# Test 1 — Create + list + detail round-trip
# ---------------------------------------------------------------------------
_COORD_HEADERS = {"X-Test-User": "user-1", "X-Test-Perms": "admin.coordinator"}
def test_create_list_detail_lifecycle(tmp_path):
"""POST /new → appears in GET / → GET /{ws_id} returns correct detail."""
storage = SQLiteBackend(str(tmp_path / "coord.db"))
mgr = _build_mgr(storage)
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
# --- Create ---
resp = client.post(
"/v1/api/workstreams/new",
json={"name": "e2e-coord"},
headers=_COORD_HEADERS,
)
assert resp.status_code == 200, resp.text
body = resp.json()
ws_id = body["ws_id"]
assert ws_id
assert "e2e-coord" in body["name"]
# --- List: caller sees their own coordinator ---
resp = client.get("/v1/api/workstreams", headers=_COORD_HEADERS)
assert resp.status_code == 200, resp.text
coordinators = resp.json()["workstreams"]
ids = {c["ws_id"] for c in coordinators}
assert ws_id in ids
# Trusted-team visibility: every ``admin.coordinator`` caller sees
# every active coordinator regardless of owner.
mgr.create(user_id="other-user", name="not-mine")
resp = client.get("/v1/api/workstreams", headers=_COORD_HEADERS)
assert resp.status_code == 200
names = {c["name"] for c in resp.json()["workstreams"]}
assert "not-mine" in names
# --- Detail ---
resp = client.get(f"/v1/api/workstreams/{ws_id}", headers=_COORD_HEADERS)
assert resp.status_code == 200, resp.text
detail = resp.json()
assert detail["ws_id"] == ws_id
assert detail["kind"] == "coordinator"
assert detail["user_id"] == "user-1"
# --- Close ---
resp = client.post(f"/v1/api/workstreams/{ws_id}/close", headers=_COORD_HEADERS)
assert resp.status_code == 200
# Manager no longer tracks it after close.
assert mgr.get(ws_id) is None
# Storage row reflects closed state.
row = storage.get_workstream(ws_id)
assert row is not None
assert row["state"] == "closed"
# Detail endpoint returns 404 after close (not in memory, not rehydratable
# from a "closed" row — well, the manager would rehydrate it but let's verify
# the row is gone from the in-memory index).
assert mgr.get(ws_id) is None
# ---------------------------------------------------------------------------
# Test 2 — CoordinatorClient against a MockTransport "server node" stub
# ---------------------------------------------------------------------------
def test_coordinator_client_spawn_close_delete(tmp_path):
"""CoordinatorClient.spawn / close_workstream / delete produce correct
upstream HTTP requests to the mocked server node."""
storage = SQLiteBackend(str(tmp_path / "client.db"))
# Register the coordinator + the soon-to-be-spawned child so the
# client-side tenant guard on close/delete passes. In production
# the spawn route adds the child row before the model can call
# close on it; the test stub doesn't run that side-effect, so we
# set it up here.
storage.register_workstream("coord-42", kind="coordinator", user_id="user-1")
storage.register_workstream(
"child-99", kind="interactive", parent_ws_id="coord-42", user_id="user-1"
)
captured: list[httpx.Request] = []
def _handler(req: httpx.Request) -> httpx.Response:
captured.append(req)
path = req.url.path
if path == "/v1/api/route/workstreams/new":
return httpx.Response(
201,
json={"ws_id": "child-99", "name": "spawned", "node_id": "node-a"},
)
# close and delete both return a generic ok
return httpx.Response(200, json={"status": "ok"})
transport = httpx.MockTransport(_handler)
http = httpx.Client(transport=transport)
coord_client = CoordinatorClient(
console_base_url="http://console",
storage=storage,
token_factory=lambda: "bearer-test-token",
coord_ws_id="coord-42",
user_id="user-1",
http_client=http,
)
# spawn ---------------------------------------------------------------
result = coord_client.spawn(
initial_message="analyse data",
parent_ws_id="coord-42",
user_id="user-1",
skill="data-skill",
target_node="node-a",
)
assert result["ws_id"] == "child-99"
spawn_req = captured[0]
assert spawn_req.method == "POST"
assert spawn_req.url.path == "/v1/api/route/workstreams/new"
assert spawn_req.headers["Authorization"] == "Bearer bearer-test-token"
spawn_body = json.loads(spawn_req.content)
assert spawn_body["kind"] == "interactive"
assert spawn_body["parent_ws_id"] == "coord-42"
assert spawn_body["user_id"] == "user-1"
assert spawn_body["initial_message"] == "analyse data"
assert spawn_body["skill"] == "data-skill"
assert spawn_body["target_node"] == "node-a"
# close_workstream ----------------------------------------------------
captured.clear()
close_result = coord_client.close_workstream("child-99")
assert close_result.get("status") in (200, "ok"), close_result
close_req = captured[0]
# Path-keyed shape post-#422: ws_id rides in the URL.
assert close_req.url.path == "/v1/api/route/workstreams/child-99/close"
close_body = json.loads(close_req.content)
# Body no longer carries ws_id — the path is authoritative.
assert "ws_id" not in close_body
# delete --------------------------------------------------------------
captured.clear()
del_result = coord_client.delete("child-99")
assert del_result.get("status") in (200, "ok"), del_result
del_req = captured[0]
assert del_req.url.path == "/v1/api/route/workstreams/delete"
del_body = json.loads(del_req.content)
assert del_body["ws_id"] == "child-99"
# ---------------------------------------------------------------------------
# Test 3 — list_children storage read: kind filtering + parent scoping
# ---------------------------------------------------------------------------
@pytest.fixture()
def seeded_storage(tmp_path):
"""SQLiteBackend with a coordinator + 2 interactive children + extras."""
st = SQLiteBackend(str(tmp_path / "seed.db"))
# Parent coordinator.
st.register_workstream("coord-root", kind="coordinator", user_id="user-1")
# Two interactive children — one idle, one running. Children inherit
# the coord's user_id by construction (server-side create gate), which
# the list_children SQL filter now enforces.
st.register_workstream(
"child-idle",
kind="interactive",
parent_ws_id="coord-root",
state="idle",
skill_id="skill-alpha",
user_id="user-1",
)
st.register_workstream(
"child-running",
kind="interactive",
parent_ws_id="coord-root",
state="running",
skill_id="skill-beta",
user_id="user-1",
)
# Coordinator child — MUST be excluded from list_children results.
st.register_workstream(
"child-coord",
kind="coordinator",
parent_ws_id="coord-root",
user_id="user-1",
)
# Unrelated workstream with no parent — MUST be excluded.
st.register_workstream("unrelated-ws", kind="interactive", user_id="user-1")
return st
def _read_client(storage: SQLiteBackend) -> CoordinatorClient:
"""Build a CoordinatorClient whose HTTP transport is a no-op stub."""
transport = httpx.MockTransport(lambda r: httpx.Response(200))
http = httpx.Client(transport=transport)
return CoordinatorClient(
console_base_url="http://x",
storage=storage,
token_factory=lambda: "t",
coord_ws_id="coord-root",
user_id="user-1",
http_client=http,
)
def test_list_children_excludes_coordinator_and_unrelated_rows(seeded_storage):
"""list_children returns only interactive children of the given parent."""
client = _read_client(seeded_storage)
result = client.list_children("coord-root")
rows = result["children"]
ws_ids = {r["ws_id"] for r in rows}
# The two interactive children are present.
assert ws_ids == {"child-idle", "child-running"}
# Every returned row must be interactive and linked to coord-root.
for r in rows:
assert r["kind"] == "interactive"
assert r["parent_ws_id"] == "coord-root"
# Coordinator child and unrelated ws are absent.
assert "child-coord" not in ws_ids
assert "unrelated-ws" not in ws_ids
assert result["truncated"] is False
def test_list_children_state_filter(seeded_storage):
"""list_children(state='running') filters to only running children."""
client = _read_client(seeded_storage)
result = client.list_children("coord-root", state="running")
assert {r["ws_id"] for r in result["children"]} == {"child-running"}
def test_list_children_skill_filter(seeded_storage):
"""list_children(skill='skill-alpha') returns the matching child only."""
client = _read_client(seeded_storage)
result = client.list_children("coord-root", skill="skill-alpha")
rows = result["children"]
assert {r["ws_id"] for r in rows} == {"child-idle"}
assert rows[0].get("skill_id") == "skill-alpha"
# ---------------------------------------------------------------------------
# Test 4 — Lazy rehydration via GET /v1/api/workstreams/{ws_id}
# ---------------------------------------------------------------------------
def test_lazy_rehydration_on_detail_get(tmp_path):
"""A persisted coordinator row rehydrates into the manager on GET /{ws_id}.
Sequence:
1. Pre-seed storage with a coordinator row (simulating a previous process).
2. Build a SessionManager (coordinator kind) that doesn't know about it yet.
3. Hit GET /v1/api/workstreams/{ws_id} expect 200.
4. Manager now tracks the rehydrated session.
5. The response body carries the correct kind / user_id metadata.
"""
storage = SQLiteBackend(str(tmp_path / "rehydrate.db"))
# Seed the row directly — the manager has never seen it.
storage.register_workstream(
"persisted-coord",
node_id="console",
user_id="user-1",
name="old-coord",
kind="coordinator",
)
mgr = _build_mgr(storage)
# Confirm: not tracked in memory yet.
assert mgr.get("persisted-coord") is None
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.get("/v1/api/workstreams/persisted-coord", headers=_COORD_HEADERS)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["ws_id"] == "persisted-coord"
assert body["kind"] == "coordinator"
assert body["user_id"] == "user-1"
# The endpoint triggers lazy rehydration — manager now tracks it.
assert mgr.get("persisted-coord") is not None
# Trusted-team visibility: any admin.coordinator caller can read
# the coordinator's detail, regardless of ``user_id``.
resp_stranger = client.get(
"/v1/api/workstreams/persisted-coord",
headers={"X-Test-User": "stranger", "X-Test-Perms": "admin.coordinator"},
)
assert resp_stranger.status_code == 200
assert resp_stranger.json()["user_id"] == "user-1"
# A workstream with kind='interactive' is not reachable via the coordinator
# endpoint even when it exists in storage.
storage.register_workstream("interactive-ws", kind="interactive", user_id="user-1")
resp_int = client.get("/v1/api/workstreams/interactive-ws", headers=_COORD_HEADERS)
assert resp_int.status_code == 404
File diff suppressed because it is too large Load Diff
+816
View File
@@ -0,0 +1,816 @@
"""Tests for the coordinator governance endpoints and session hooks.
Covers the three console endpoints that let an operator steer a live
coordinator session mid-flight (``/trust``, ``/restrict``,
``/stop_cascade``), the two ``ChatSession`` methods the endpoints
toggle (``set_trust_send`` / ``revoke_tools``), the audit rows the
handlers emit, and the ``_prepare_tool`` revocation gate.
"""
from __future__ import annotations
import json
from typing import Any
from unittest.mock import MagicMock
import pytest
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.routing import Route
from starlette.testclient import TestClient
from tests._coord_test_helpers import (
_AuthMiddleware,
_build_mgr,
_fake_registry,
_FakeConfigStore,
_seed_children,
)
from turnstone.console.server import (
coordinator_restrict,
coordinator_stop_cascade,
coordinator_trust,
)
from turnstone.core.auth import AuthResult
from turnstone.core.storage._sqlite import SQLiteBackend
@pytest.fixture
def storage(tmp_path):
return SQLiteBackend(str(tmp_path / "coord.db"))
def _make_client(storage, *, coord_mgr, alias="my-model", registry=None) -> TestClient:
"""Starlette app exposing only the three governance endpoints."""
app = Starlette(
routes=[
Route(
"/v1/api/workstreams/{ws_id}/trust",
coordinator_trust,
methods=["POST"],
),
Route(
"/v1/api/workstreams/{ws_id}/restrict",
coordinator_restrict,
methods=["POST"],
),
Route(
"/v1/api/workstreams/{ws_id}/stop_cascade",
coordinator_stop_cascade,
methods=["POST"],
),
],
middleware=[Middleware(_AuthMiddleware)],
)
app.state.coord_mgr = coord_mgr
app.state.coord_adapter = coord_mgr._adapter if coord_mgr is not None else None
app.state.config_store = _FakeConfigStore({"coordinator.model_alias": alias})
app.state.coord_registry = registry
app.state.coord_registry_error = "" if coord_mgr else "registry missing"
app.state.auth_storage = storage
app.state.jwt_secret = "x" * 64
return TestClient(app)
def _make_session_mock(*, trust_send: bool = False, revoked: frozenset[str] = frozenset()):
"""Build a MagicMock ``session`` that honours the new ChatSession
governance surface (``set_trust_send`` / ``get_trust_send`` /
``revoke_tools`` / ``get_revoked_tools``) so handler tests exercise
the real method calls rather than reaching into attributes."""
state: dict[str, Any] = {"trust_send": trust_send, "revoked": revoked}
def _set_trust_send(value: bool) -> None:
state["trust_send"] = bool(value)
def _get_trust_send() -> bool:
return bool(state["trust_send"])
def _revoke_tools(names):
state["revoked"] = state["revoked"] | frozenset(names)
return state["revoked"]
def _get_revoked_tools():
return state["revoked"]
session = MagicMock()
session.set_trust_send.side_effect = _set_trust_send
session.get_trust_send.side_effect = _get_trust_send
session.revoke_tools.side_effect = _revoke_tools
session.get_revoked_tools.side_effect = _get_revoked_tools
return session, state
_COORD_HEADERS = {"X-Test-User": "user-1", "X-Test-Perms": "admin.coordinator"}
_TRUST_HEADERS = {
"X-Test-User": "user-1",
"X-Test-Perms": "admin.coordinator,coordinator.trust.send",
}
# ---------------------------------------------------------------------------
# /trust endpoint — trusted-session mode (item 1)
# ---------------------------------------------------------------------------
def test_trust_toggle_requires_trust_send_permission(storage):
"""Double-gated: admin.coordinator alone is insufficient — the
trust-send perm is an explicit opt-in."""
mgr = _build_mgr(storage)
coord = mgr.create(user_id="user-1", name="coord-a")
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
f"/v1/api/workstreams/{coord.id}/trust",
json={"send": True},
headers=_COORD_HEADERS,
)
assert resp.status_code == 403
def test_trust_toggle_flips_session_flag_and_audits(storage):
mgr = _build_mgr(storage)
coord = mgr.create(user_id="user-1", name="coord-a")
session, state = _make_session_mock()
coord.session = session
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
f"/v1/api/workstreams/{coord.id}/trust",
json={"send": True},
headers=_TRUST_HEADERS,
)
assert resp.status_code == 200
assert resp.json() == {"status": "ok", "trust_send": True}
assert state["trust_send"] is True
events = [e for e in storage.list_audit_events() if e["action"] == "coordinator.trust.toggled"]
assert len(events) == 1
detail = json.loads(events[0]["detail"])
assert detail["send_before"] is False
assert detail["send_after"] is True
def _service_token_client(
storage,
coord_mgr,
*,
user_id: str,
permissions: frozenset[str],
) -> TestClient:
"""Build a TestClient whose middleware injects a service-scoped token.
Used to verify that the capability-escalating endpoints (``/trust``,
``/restrict``, ``/stop_cascade``) do NOT honor the normal
``require_permission`` service-scope bypass when the caller lacks
the specific grant they need.
"""
app = Starlette(
routes=[
Route(
"/v1/api/workstreams/{ws_id}/trust",
coordinator_trust,
methods=["POST"],
),
Route(
"/v1/api/workstreams/{ws_id}/restrict",
coordinator_restrict,
methods=["POST"],
),
Route(
"/v1/api/workstreams/{ws_id}/stop_cascade",
coordinator_stop_cascade,
methods=["POST"],
),
],
)
app.state.coord_mgr = coord_mgr
app.state.coord_adapter = coord_mgr._adapter if coord_mgr is not None else None
app.state.config_store = _FakeConfigStore({"coordinator.model_alias": "my-model"})
app.state.coord_registry = _fake_registry()
app.state.coord_registry_error = ""
app.state.auth_storage = storage
app.state.jwt_secret = "x" * 64
captured_perms = permissions
class _ServiceAuth(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
request.state.auth_result = AuthResult(
user_id=user_id,
scopes=frozenset({"read", "write", "approve", "service"}),
token_source="test",
permissions=captured_perms,
)
return await call_next(request)
app.user_middleware = [Middleware(_ServiceAuth)]
app.middleware_stack = app.build_middleware_stack()
return TestClient(app)
def test_trust_toggle_service_token_cannot_bypass_permission(storage):
"""Service token without coordinator.trust.send is 403'd even when
its user_id matches the coord owner."""
mgr = _build_mgr(storage)
coord = mgr.create(user_id="svc-user", name="coord-a")
coord.session, _ = _make_session_mock()
client = _service_token_client(
storage,
mgr,
user_id="svc-user",
permissions=frozenset({"admin.coordinator"}),
)
resp = client.post(
f"/v1/api/workstreams/{coord.id}/trust",
json={"send": True},
)
assert resp.status_code == 403
assert "coordinator.trust.send" in resp.json()["error"]
def test_trust_toggle_service_token_with_permission_succeeds(storage):
"""Service token WITH the explicit coordinator.trust.send grant IS
allowed through locks the intended invariant: bypass is off, but
an explicit perm still works."""
mgr = _build_mgr(storage)
coord = mgr.create(user_id="svc-user", name="coord-a")
session, state = _make_session_mock()
coord.session = session
client = _service_token_client(
storage,
mgr,
user_id="svc-user",
permissions=frozenset({"admin.coordinator", "coordinator.trust.send"}),
)
resp = client.post(
f"/v1/api/workstreams/{coord.id}/trust",
json={"send": True},
)
assert resp.status_code == 200
assert resp.json() == {"status": "ok", "trust_send": True}
assert state["trust_send"] is True
def test_restrict_service_token_cannot_bypass_admin_coordinator(storage):
"""/restrict is destructive — a service token WITHOUT explicit
admin.coordinator grant must be 403'd rather than letting the
service-scope bypass open the endpoint up."""
mgr = _build_mgr(storage)
coord = mgr.create(user_id="svc-user", name="coord-a")
coord.session, _ = _make_session_mock()
client = _service_token_client(
storage,
mgr,
user_id="svc-user",
permissions=frozenset(), # no admin.coordinator
)
resp = client.post(
f"/v1/api/workstreams/{coord.id}/restrict",
json={"revoke": ["bash"]},
)
assert resp.status_code == 403
def test_stop_cascade_service_token_cannot_bypass_admin_coordinator(storage):
"""/stop_cascade mirrors /restrict — same destructive treatment."""
mgr = _build_mgr(storage)
coord = mgr.create(user_id="svc-user", name="coord-a")
coord.session, _ = _make_session_mock()
client = _service_token_client(
storage,
mgr,
user_id="svc-user",
permissions=frozenset(),
)
resp = client.post(
f"/v1/api/workstreams/{coord.id}/stop_cascade",
json={},
)
assert resp.status_code == 403
def test_trust_toggle_rejects_non_bool(storage):
mgr = _build_mgr(storage)
coord = mgr.create(user_id="user-1", name="coord-a")
coord.session, _ = _make_session_mock()
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
f"/v1/api/workstreams/{coord.id}/trust",
json={"send": "yes"},
headers=_TRUST_HEADERS,
)
assert resp.status_code == 400
def test_trust_toggle_rejects_non_object_body(storage):
"""A valid-JSON-but-non-object body (null / list / scalar) must
400 cleanly rather than AttributeError 500."""
mgr = _build_mgr(storage)
coord = mgr.create(user_id="user-1", name="coord-a")
coord.session, _ = _make_session_mock()
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
# Non-dict JSON values — all must 400. Different bodies may hit
# `read_json_or_400`'s own parse error ("Invalid JSON body") or the
# downstream dict-shape guard ("body must be a JSON object"); we
# only care that none 500.
for body in ([], 42, "string"):
resp = client.post(
f"/v1/api/workstreams/{coord.id}/trust",
json=body,
headers=_TRUST_HEADERS,
)
assert resp.status_code == 400, body
assert "JSON object" in resp.json()["error"], resp.json()
def test_restrict_rejects_non_object_body(storage):
mgr = _build_mgr(storage)
coord = mgr.create(user_id="user-1", name="coord-a")
coord.session, _ = _make_session_mock()
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
f"/v1/api/workstreams/{coord.id}/restrict",
json=[],
headers=_COORD_HEADERS,
)
assert resp.status_code == 400
def test_trust_toggle_cluster_wide_access(storage):
# Trusted-team model: the trust toggle is gated on the scope
# permission, not on row-level ownership. A caller holding
# ``coordinator.trust.send`` may toggle any coord's trust state.
mgr = _build_mgr(storage)
coord = mgr.create(user_id="user-owner", name="coord-a")
coord.session, _ = _make_session_mock()
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
f"/v1/api/workstreams/{coord.id}/trust",
json={"send": True},
headers={
"X-Test-User": "user-other",
"X-Test-Perms": "admin.coordinator,coordinator.trust.send",
},
)
assert resp.status_code == 200
def test_trust_toggle_404_when_session_not_loaded(storage):
"""Persisted-but-not-loaded coordinator: runtime session state can't
be mutated, so the endpoint 404s. Matches the tenant-miss shape
so non-admins can't probe for closed rows via this endpoint."""
mgr = _build_mgr(storage)
coord = mgr.create(user_id="user-1", name="coord-a")
coord.session = None # simulate a closed / lazy-rehydrate coord
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
f"/v1/api/workstreams/{coord.id}/trust",
json={"send": True},
headers=_TRUST_HEADERS,
)
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# _prepare_send_to_workstream — trust gate (item 1, unit-level)
# ---------------------------------------------------------------------------
def test_prepare_send_to_workstream_trust_skips_approval_for_own_child():
from turnstone.core.session import ChatSession
session = ChatSession.__new__(ChatSession)
session._coord_client = MagicMock()
session._trust_send = True
session._coord_client._is_own_subtree.return_value = True
item = session._prepare_send_to_workstream(call_id="c1", args={"ws_id": "abc", "message": "hi"})
assert item["needs_approval"] is False
assert item["trust_auto_approved"] is True
def test_prepare_send_to_workstream_trust_holds_for_foreign_ws():
from turnstone.core.session import ChatSession
session = ChatSession.__new__(ChatSession)
session._coord_client = MagicMock()
session._trust_send = True
session._coord_client._is_own_subtree.return_value = False
item = session._prepare_send_to_workstream(
call_id="c2", args={"ws_id": "foreign-ws", "message": "hi"}
)
assert item["needs_approval"] is True
assert item["trust_auto_approved"] is False
def test_prepare_send_to_workstream_without_trust_always_requires_approval():
from turnstone.core.session import ChatSession
session = ChatSession.__new__(ChatSession)
session._coord_client = MagicMock()
session._trust_send = False
session._coord_client._is_own_subtree.return_value = True
item = session._prepare_send_to_workstream(call_id="c3", args={"ws_id": "abc", "message": "hi"})
assert item["needs_approval"] is True
assert item["trust_auto_approved"] is False
def test_exec_send_to_workstream_records_trust_audit(storage):
"""The audit row fires before the HTTP send so a downstream failure
can't suppress the trail."""
from turnstone.console.coordinator_client import CoordinatorClient
from turnstone.core.session import ChatSession
client = CoordinatorClient.__new__(CoordinatorClient)
client._storage = storage
client._user_id = "user-1"
client._coord_ws_id = "coord-1"
session = ChatSession.__new__(ChatSession)
session._coord_client = client
session.ui = MagicMock()
send_mock = MagicMock(return_value={"status": "ok"})
client.send = send_mock # type: ignore[method-assign]
session._exec_send_to_workstream(
{
"call_id": "c1",
"ws_id": "child-ws-1",
"message": "please summarise",
"trust_auto_approved": True,
}
)
events = [
e for e in storage.list_audit_events() if e["action"] == "coordinator.send.auto_approved"
]
assert len(events) == 1
detail = json.loads(events[0]["detail"])
assert detail["src"] == "coordinator"
assert detail["trust"] is True
assert detail["ws_id"] == "child-ws-1"
assert "please summarise" in detail["message_preview"]
# ---------------------------------------------------------------------------
# /restrict endpoint + _prepare_tool revocation gate (item 5a)
# ---------------------------------------------------------------------------
def test_restrict_adds_to_revoked_tools_and_audits(storage):
mgr = _build_mgr(storage)
coord = mgr.create(user_id="user-1", name="coord-a")
session, state = _make_session_mock()
coord.session = session
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
f"/v1/api/workstreams/{coord.id}/restrict",
json={"revoke": ["spawn_workstream", "delete_workstream"]},
headers=_COORD_HEADERS,
)
assert resp.status_code == 200
body = resp.json()
assert set(body["revoked_tools"]) == {"spawn_workstream", "delete_workstream"}
assert state["revoked"] == frozenset({"spawn_workstream", "delete_workstream"})
events = [e for e in storage.list_audit_events() if e["action"] == "coordinator.restricted"]
assert len(events) == 1
detail = json.loads(events[0]["detail"])
assert set(detail["revoked"]) == {"spawn_workstream", "delete_workstream"}
def test_restrict_is_additive_across_calls(storage):
mgr = _build_mgr(storage)
coord = mgr.create(user_id="user-1", name="coord-a")
coord.session, _ = _make_session_mock()
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
client.post(
f"/v1/api/workstreams/{coord.id}/restrict",
json={"revoke": ["spawn_workstream"]},
headers=_COORD_HEADERS,
)
resp = client.post(
f"/v1/api/workstreams/{coord.id}/restrict",
json={"revoke": ["delete_workstream"]},
headers=_COORD_HEADERS,
)
assert resp.status_code == 200
assert set(resp.json()["revoked_tools"]) == {
"spawn_workstream",
"delete_workstream",
}
def test_restrict_empty_revoke_is_noop_but_audits(storage):
"""Empty list is accepted as a no-op write — still emits the audit
row so operators can see 'operator poked the restrict endpoint but
didn't actually revoke anything' events."""
mgr = _build_mgr(storage)
coord = mgr.create(user_id="user-1", name="coord-a")
coord.session, _state = _make_session_mock(revoked=frozenset({"spawn_workstream"}))
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
f"/v1/api/workstreams/{coord.id}/restrict",
json={"revoke": []},
headers=_COORD_HEADERS,
)
assert resp.status_code == 200
# Pre-existing revocations are preserved; no new entries were added.
assert set(resp.json()["revoked_tools"]) == {"spawn_workstream"}
events = [e for e in storage.list_audit_events() if e["action"] == "coordinator.restricted"]
assert len(events) == 1
detail = json.loads(events[0]["detail"])
assert detail["revoked"] == []
def test_restrict_rejects_non_list_body(storage):
mgr = _build_mgr(storage)
coord = mgr.create(user_id="user-1", name="coord-a")
coord.session, _ = _make_session_mock()
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
f"/v1/api/workstreams/{coord.id}/restrict",
json={"revoke": "spawn_workstream"},
headers=_COORD_HEADERS,
)
assert resp.status_code == 400
def test_restrict_rejects_oversize_list(storage):
"""Defense-in-depth cap — an admin-sized list can't blow up the
session frozenset or the audit row's detail column."""
mgr = _build_mgr(storage)
coord = mgr.create(user_id="user-1", name="coord-a")
coord.session, _ = _make_session_mock()
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
f"/v1/api/workstreams/{coord.id}/restrict",
json={"revoke": [f"tool_{i}" for i in range(500)]},
headers=_COORD_HEADERS,
)
assert resp.status_code == 400
def test_restrict_rejects_oversize_name(storage):
mgr = _build_mgr(storage)
coord = mgr.create(user_id="user-1", name="coord-a")
coord.session, _ = _make_session_mock()
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
f"/v1/api/workstreams/{coord.id}/restrict",
json={"revoke": ["x" * 1000]},
headers=_COORD_HEADERS,
)
assert resp.status_code == 400
def test_restrict_404_when_session_not_loaded(storage):
mgr = _build_mgr(storage)
coord = mgr.create(user_id="user-1", name="coord-a")
coord.session = None
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
f"/v1/api/workstreams/{coord.id}/restrict",
json={"revoke": ["bash"]},
headers=_COORD_HEADERS,
)
assert resp.status_code == 404
def test_prepare_tool_blocks_revoked_tool():
"""Revocation short-circuits BEFORE the preparer dispatch so the
model sees a clear 'revoked' error rather than a preparer-level
validation message."""
from turnstone.core.session import ChatSession
session = ChatSession.__new__(ChatSession)
session._revoked_tools = frozenset({"spawn_workstream"})
session._mcp_client = None
session.ui = MagicMock()
tc = {
"id": "call-1",
"function": {
"name": "spawn_workstream",
"arguments": '{"initial_message": "x"}',
},
}
item = session._prepare_tool(tc)
assert item["needs_approval"] is False
assert "revoked" in item["header"].lower()
assert "revoked" in item["error"].lower()
def test_prepare_tool_allows_non_revoked_tool():
"""The revocation gate must not fire on a tool name that isn't in
the revoked set. We pick a name that's also not in the preparers
dict so we can assert the 'unknown tool' result shape without
exercising a real preparer."""
from turnstone.core.session import ChatSession
session = ChatSession.__new__(ChatSession)
session._revoked_tools = frozenset({"spawn_workstream"})
session._mcp_client = None
session.ui = MagicMock()
tc = {
"id": "call-2",
"function": {"name": "this_tool_is_not_registered", "arguments": "{}"},
}
item = session._prepare_tool(tc)
# Unknown tool path — not the revocation error path.
err = str(item.get("error") or "")
assert "revoked" not in err.lower()
# ---------------------------------------------------------------------------
# /stop_cascade endpoint (item 5b)
# ---------------------------------------------------------------------------
def test_stop_cascade_cancels_coord_and_each_child(storage):
mgr = _build_mgr(storage)
coord = mgr.create(user_id="user-1", name="coord-a")
_seed_children(mgr._adapter, coord.id, ["child-1", "child-2", "child-3"])
def _cancel(wid: str) -> dict:
if wid == "child-2":
return {"error": "gateway_timeout", "status": 502}
return {"status": "ok"}
coord_client = MagicMock()
coord_client.cancel.side_effect = _cancel
coord.session = MagicMock()
coord.session._coord_client = coord_client
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
f"/v1/api/workstreams/{coord.id}/stop_cascade",
json={},
headers=_COORD_HEADERS,
)
assert resp.status_code == 200
body = resp.json()
assert set(body["cancelled"] + body["failed"] + body["skipped"]) == {
"child-1",
"child-2",
"child-3",
}
assert body["failed"] == ["child-2"]
assert set(body["cancelled"]) == {"child-1", "child-3"}
assert body["skipped"] == []
assert coord_client.cancel.call_count == 3
events = [
e for e in storage.list_audit_events() if e["action"] == "coordinator.stopped_cascade"
]
assert len(events) == 1
detail = json.loads(events[0]["detail"])
assert set(detail["cancelled"] + detail["failed"] + detail["skipped"]) == {
"child-1",
"child-2",
"child-3",
}
def test_stop_cascade_routes_404_to_skipped_bucket(storage):
"""A stale registry entry (child row already deleted from storage)
or an upstream-404 on cancel is semantically 'already gone', not a
dispatch failure. Report it in ``skipped`` so operators can tell
them apart."""
mgr = _build_mgr(storage)
coord = mgr.create(user_id="user-1", name="coord-a")
_seed_children(mgr._adapter, coord.id, ["stale-child"])
coord_client = MagicMock()
coord_client.cancel.return_value = {
"error": "workstream not in coordinator subtree: stale-child",
"status": 404,
}
coord.session = MagicMock()
coord.session._coord_client = coord_client
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
f"/v1/api/workstreams/{coord.id}/stop_cascade",
json={},
headers=_COORD_HEADERS,
)
assert resp.status_code == 200
body = resp.json()
assert body["cancelled"] == []
assert body["failed"] == []
assert body["skipped"] == ["stale-child"]
def test_stop_cascade_empty_children_still_audits(storage):
mgr = _build_mgr(storage)
coord = mgr.create(user_id="user-1", name="coord-a")
coord.session = MagicMock()
coord.session._coord_client = MagicMock()
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
f"/v1/api/workstreams/{coord.id}/stop_cascade",
json={},
headers=_COORD_HEADERS,
)
assert resp.status_code == 200
body = resp.json()
assert body == {"status": "ok", "cancelled": [], "failed": [], "skipped": []}
assert [e for e in storage.list_audit_events() if e["action"] == "coordinator.stopped_cascade"]
def test_stop_cascade_without_coord_client_marks_all_failed(storage):
"""If the coord session has no attached coord_client (unexpected
state for a loaded session), every child routes to ``failed`` so
the operator can investigate."""
mgr = _build_mgr(storage)
coord = mgr.create(user_id="user-1", name="coord-a")
_seed_children(mgr._adapter, coord.id, ["child-a", "child-b"])
coord.session = MagicMock()
coord.session._coord_client = None
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
f"/v1/api/workstreams/{coord.id}/stop_cascade",
json={},
headers=_COORD_HEADERS,
)
assert resp.status_code == 200
body = resp.json()
assert body["cancelled"] == []
assert body["skipped"] == []
assert set(body["failed"]) == {"child-a", "child-b"}
def test_stop_cascade_404_when_session_not_loaded(storage):
mgr = _build_mgr(storage)
coord = mgr.create(user_id="user-1", name="coord-a")
coord.session = None
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
f"/v1/api/workstreams/{coord.id}/stop_cascade",
json={},
headers=_COORD_HEADERS,
)
assert resp.status_code == 404
def test_children_snapshot_returns_copy_not_live_set(storage):
mgr = _build_mgr(storage)
coord = mgr.create(user_id="user-1", name="coord-a")
_seed_children(mgr._adapter, coord.id, ["a", "b", "c"])
snap = mgr._adapter.children_snapshot(coord.id)
assert set(snap) == {"a", "b", "c"}
_seed_children(mgr._adapter, coord.id, ["d"])
assert set(snap) == {"a", "b", "c"}
# ---------------------------------------------------------------------------
# ChatSession governance methods (q-14) — unit-level
# ---------------------------------------------------------------------------
def test_set_and_get_trust_send_round_trip():
from turnstone.core.session import ChatSession
session = ChatSession.__new__(ChatSession)
import threading as _t
session._trust_send = False
session._governance_lock = _t.Lock()
assert session.get_trust_send() is False
session.set_trust_send(True)
assert session.get_trust_send() is True
session.set_trust_send(False)
assert session.get_trust_send() is False
def test_revoke_tools_is_additive_and_returns_post_state():
from turnstone.core.session import ChatSession
session = ChatSession.__new__(ChatSession)
import threading as _t
session._revoked_tools = frozenset()
session._governance_lock = _t.Lock()
after = session.revoke_tools(["bash", "read_file"])
assert after == frozenset({"bash", "read_file"})
after2 = session.revoke_tools(["write_file"])
assert after2 == frozenset({"bash", "read_file", "write_file"})
# Re-revoking is a no-op (idempotent).
after3 = session.revoke_tools(["bash"])
assert after3 == after2
assert session.get_revoked_tools() == after3

Some files were not shown because too many files have changed in this diff Show More