Compare commits

..

624 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
Patrick Buckley b180770eff chore: bump version to 1.0.0 2026-04-02 17:09:31 -07:00
Patrick Buckley 9d2e11f2be chore: update classifier to Production/Stable for 1.0 2026-04-02 17:09:10 -07:00
Patrick Buckley 57080f4615 chore: release infrastructure for dual-track stable/experimental (#282)
* chore: release infrastructure for dual-track stable/experimental

CI/CD changes for the 1.0 release:

- Gate PyPI publish and Docker publish on CI success via workflow_run
- Add docker-publish.yml: builds and pushes to GHCR with smart tagging
  (stable gets :X.Y.Z/:X.Y/:stable/:latest, pre-release gets :experimental)
- Add stable/* and v* tags to CI and docker-scan triggers
- Remove stale [mq] extra and types-redis from CI (Redis MQ deleted)
- Remove stale redis from Renovate package rules

Release tooling:
- scripts/release.sh: bump version, uv lock, commit, tag (with --push)
- docs/releasing.md: documents stable/experimental workflow

Docker:
- Add /workspace mount point (WORKSPACE_MOUNT env var, defaults to empty volume)
- Update .env.example: remove stale Redis/auth-token refs, add workspace/model/discord

README:
- Remove beta warning, add hero image and release tracks table

* fix: derive release tag from git instead of workflow_run.head_branch

Use git tag --points-at HEAD after checkout to resolve the release
tag instead of relying on workflow_run.head_branch, which may not
reliably be the tag name for tag-triggered CI runs. Both publish
and docker-publish workflows now skip cleanly when no v* tag exists
at the checked-out commit.
2026-04-02 17:08:07 -07:00
Patrick Buckley 45f27fb2a7 fix: replay plan review prompt on SSE reconnection (#281)
* fix: replay plan review prompt on SSE reconnection

Plan approval prompts were lost when a user navigated to the server
web UI from the console dashboard (triggering a new SSE connection).
Tool approvals stored pending state in _pending_approval and replayed
it on reconnection, but plan reviews used fire-and-forget _enqueue
with no persistent state.

Mirror the _pending_approval pattern: store _pending_plan_review
before blocking, replay it in events_sse for new SSE clients, and
clear it on resolution. Without this fix, plan reviews silently
timed out after 1 hour and were treated as approval.

* test: add plan review SSE replay regression tests

Covers pending state lifecycle: stored during on_plan_review, cleared
on resolve_plan, available for SSE reconnection replay.
2026-04-02 16:47:51 -07:00
Patrick Buckley ebc8e75285 fix(sdk): add token_factory param to sync TurnstoneServer and TurnstoneConsole (#280)
The async variants accepted token_factory for auto-rotating JWTs via
ServiceTokenManager, but the sync wrappers did not expose or forward
the parameter. External SDK users calling the sync clients with
token_factory got a TypeError.
2026-04-02 15:42:17 -07:00
Patrick Buckley 485af92f7f fix(console): top-align admin grid rows to fix badge/input drift (#279)
Settings rows and admin table rows used align-items: center, which
caused inputs and source badges to drift away from their labels when
descriptions wrapped to multiple lines. Switch to align-items: start
so controls stay next to their label names regardless of row height.

Add 2px top margin on settings toggles to pixel-align with text input
top padding in start-aligned rows.
2026-04-02 15:20:29 -07:00
Patrick Buckley 664d44c109 fix(examples): rewrite mcp-cluster-ops to use console SDK for cluster… (#278)
* fix(examples): rewrite mcp-cluster-ops to use console SDK for cluster routing

The example MCP server was broken after the direct HTTP transport
refactor — it used TurnstoneServer (single-node) for cluster ops that
require TurnstoneConsole (cluster gateway). Rewrites dispatch flow to:
route via console → SSE stream from node → cleanup via console.

- Switch from TurnstoneServer to TurnstoneConsole for node listing and
  workstream routing (TURNSTONE_CONSOLE_URL replaces TURNSTONE_SERVER_URL)
- Add proper workstream lifecycle: create via routing proxy, stream from
  node, close in finally block with leak-safe ws_id guard
- Catch dispatch exceptions in run_on_node for structured JSON errors
- Extract _extract_node_ids helper, remove dead n.get("id") fallback
- Normalise _console_kwargs to always include token key
- Rewrite tests against Console+Server mocks (36 → 44 tests)

* fix(examples): paginate node listing and clarify auth in README

Address Copilot review feedback on #278:
- _list_nodes_sync now paginates via offset/limit loop so clusters
  with >100 nodes are fully discovered
- README step 2 now mentions token passthrough for authenticated clusters
- New test_paginates_large_clusters verifies multi-page fetch (45 tests)
2026-04-02 15:12:41 -07:00
renovate[bot] 3cf9485169 chore(deps): update dependency mermaid to v11.14.0 (#276)
* chore(deps): update dependency mermaid to v11.14.0

* 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-01 19:46:24 -07:00
renovate[bot] d43b9d1647 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.11.3 (#275)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-01 19:46:14 -07:00
renovate[bot] ea8d9d1798 chore(deps): lock file maintenance (#277)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-01 19:46:06 -07:00
Patrick Buckley d9aa50dca9 chore: trivy ignore 5 transitive npm CVEs (minimatch, picomatch, tar) 2026-04-01 19:42:03 -07:00
Patrick Buckley 6f89d0cc13 chore: bump version to 0.9.10 2026-04-01 19:40:17 -07:00
Patrick Buckley 62d2a0fe6a fix: remove non-auth support from bootstrap wizard (#274)
* fix: remove non-auth support from bootstrap wizard

Auth is now mandatory for all deployments. Remove the
TURNSTONE_AUTH_ENABLED toggle and make JWT_SECRET and AUTH_TOKEN
required in the wizard's system prompt.

* fix: remove auth disable support from runtime and infra

Remove AuthConfig.enabled field — auth is always on. Drop
TURNSTONE_AUTH_ENABLED env var, config toggle, and the
check_request bypass. Update compose.yaml, Helm chart,
Terraform, docs, and tests to match.

* feat: deprecate config tokens, require JWT secret, prefer JWT auth

Phase 1 of config-token removal:

- load_jwt_secret() now exits with error if no secret is configured
  (was: silently auto-generated ephemeral secret)
- _authenticate_token() logs deprecation warning on config token use
- CLI /cluster commands use ServiceTokenManager when JWT secret is set
- turnstone-admin tls-list uses ServiceTokenManager when JWT secret is set
- Update bootstrap wizard, docker.md, security.md to mark
  TURNSTONE_AUTH_TOKEN as deprecated and JWT_SECRET as required
- Console test fixtures use auth token + headers (auth always enforced)

* feat: add service scope for inter-service JWT auth

Add "service" to VALID_SCOPES and SCOPE_HIERARCHY. Service tokens
bypass require_permission() RBAC checks, replacing the old
empty-user-id bypass that config tokens relied on.

All ServiceTokenManager instances that need admin access now include
"service" in their scopes (console proxy, channel gateway, CLI,
admin CLI). Read-only services (collector, notification) unchanged.

* feat: phase 2 config token deprecation

- SDK doc examples now show API tokens (ts_) instead of config tokens
- Remove _get_config_token() from admin CLI (dead code)
- Block config token exchange in handle_auth_login — only password
  and API token login allowed
- Update login tests to use password-based auth instead of config
  token exchange

* feat: phase 3 — remove config tokens entirely

Complete removal of config-file token authentication:

- Delete AuthConfig.tokens, check(), _ROLE_TO_SCOPES, hmac dispatch
  branch, and config token loading from load_auth_config()
- Remove auth_config parameter from _authenticate_token() and
  check_request() — callers updated throughout
- Remove TURNSTONE_AUTH_TOKEN from compose.yaml, Helm charts,
  Terraform, turnstone.example.toml
- Remove --auth-token CLI flags from turnstone, turnstone-admin,
  and turnstone-console
- Simplify console main() — always use ServiceTokenManager
  (no fallback to static tokens)
- Delete config-token-specific tests, rewrite check_request and
  integration tests to use JWT auth with proper audience claims
- Remove all config token references from docs (security.md,
  docker.md, sdk.md, console.md, architecture.md, bootstrap prompt)

* fix: address code review findings

- Fix 33 broken tests: add JWT auth to test_api_versioning,
  test_console_routing_proxy, test_tls_admin, test_tls_manager,
  test_server_live (jwt_secret + audience-scoped auth headers)
- Add TestRequirePermissionServiceScope: 4 tests covering the
  service scope RBAC bypass path
- Remove stale comments referencing config tokens in auth.py and
  console/server.py
- Remove dead proxy_auth_token parameter from console create_app()
  and static token fallback in _proxy_auth_headers()
- Remove TURNSTONE_AUTH_TOKEN from env.py scrub list

* fix: address Copilot review — JWT audience, compose require secret

- CLI /cluster: add audience=JWT_AUD_CONSOLE to ServiceTokenManager
  (console validates audience, JWTs without it were rejected)
- Admin CLI tls-list: same audience fix
- compose.yaml: TURNSTONE_JWT_SECRET now uses :? to fail fast if unset
- SDK console: fix default port from 8081 to 8090

* test: add auth enforcement tests for TLS admin endpoints

5 new tests: unauthenticated requests return 401 (list, renew,
delete), read-only-scoped requests return 403 (renew, delete).
Closes the TLS auth enforcement test gap noted in PROGRESS.md.

* fix: address remaining Copilot review feedback

- Fix token_source="config" → "test" in TLS test fixtures
- Fix AuthResult.token_source docstring to include service origins
- Require TURNSTONE_JWT_SECRET in cluster compose profile (:?)
- Helm: add auth.jwtSecret + auth.existingSecret values, wire
  TURNSTONE_JWT_SECRET into secret.yaml and both deployments
- Terraform: replace auth_token with jwt_secret variable + secret,
  remove orphaned auth_token resources and IAM reference
- Remove [[auth.tokens]] from security.md config example

* fix: address full code review — 10 findings

Critical:
- Terraform: replace concat(common_env, auth_env) with common_env
  (auth_env local was removed but still referenced)
- Channel gateway: remove hmac static token auth from _check_auth(),
  use JWT-only validation. Remove --auth-token CLI arg from channel
- Rebalancer: add token_manager support so migration requests carry
  JWT auth (was sending unauthenticated POST to /internal/migrate)

Major:
- Guard _permissions_to_scopes() against "service" privilege
  escalation from DB role permissions
- Remove dead AuthConfig class, load_auth_config(), and all
  auth_config parameters from create_app() signatures
- Helm: inject JWT secret for both inline and existingSecret paths

Minor:
- Remove dead auth_token param from ClusterCollector
- Remove empty TestLoadAuthConfig class
- Short JWT secret now exits instead of warning
- Compose: add generation command comment above JWT_SECRET
- Clean stale config token references from 6 doc files
- Clean stale AUTH_TOKEN reference from bootstrap wizard prompt

* fix: remove remaining stale config token references from docs

- channels.md: remove --auth-token from options table
- oidc.md: remove "config-file tokens still work" claim
- security.md: remove config token section, fix JWT secret docs
  (now required/exits, no ephemeral fallback), remove hmac from
  ASCII diagram, remove --auth-token reference
2026-04-01 19:38:24 -07:00
Patrick Buckley 5df37f83a7 fix: populate model in _last_usage so usage-by-model records correctly (#273)
* fix: populate model in _last_usage so usage-by-model records correctly

_last_usage was built purely from UsageInfo token counts, never
including a "model" key.  server.py's on_status() fell back to
model="" for every record_usage_event call, so GROUP BY model
collapsed all rows into a single empty-key bucket.

* fix: inject model at emission time, preserve dict[str, int] typing

Address Copilot review: keep _last_usage as dict[str, int] for type
safety, inject "model" from self.model when passing to on_status().
This also fixes stale model after /model switch since the value is
read fresh each time.
2026-04-01 13:21:48 -07:00
Patrick Buckley 651c4d98cd fix: MCP tools not surfacing after Sync to Nodes, update Anthropic to… (#272)
* fix: MCP tools not surfacing after Sync to Nodes, update Anthropic tool search

Three fixes:

1. session_factory closure captured mcp_client=None when no --mcp-config
   was passed at startup. internal_mcp_reload created a new MCPClientManager
   on app.state but the factory never saw it. New workstreams got 0 MCP tools.
   Fix: mutable _mcp_ref list shared between factory and reload handler.

2. Anthropic dropped the date suffix from tool_search_tool_bm25_20251119
   and now requires name == type. Updated constant and tool definition.

3. Add diagnostic logging around API errors (provider, model, base_url,
   message counts, full exception chain) and workstream resume (pre/post
   provider state, alias resolution warnings).

Also adds Node.js 24 LTS to Dockerfile via multi-stage copy for npx-based
MCP servers.

* fix: address Copilot review — set_storage on reload, sanitize log output

- Call mcp_mgr.set_storage(storage) when internal_mcp_reload creates a
  new MCPClientManager so prompt sync works for post-startup servers
- Strip query params from base_url before logging (may contain API keys
  in some vLLM deployments)
- Split API error logging: concise warning (type names only) + separate
  debug with exc_info=True for full traceback when needed

* chore: remove DDG MCP sidecar, web_search uses built-in ddgs client

The DuckDuckGo MCP server container is redundant — the built-in
DuckDuckGoClient (via ddgs package, included in all extras) auto-detects
when no Tavily key is configured. Removes the ddg-search service,
ddgCluster profile, and mcp-ddg.json config file.
2026-04-01 12:42:09 -07:00
Patrick Buckley e901e859c7 fix: materialize skill resources to disk for subprocess access (#271)
* fix: materialize skill resources to disk for subprocess access

Skill-bundled scripts stored in skill_resources were loaded into memory
but never written to disk, causing FileNotFoundError when the model
tried to execute them. Write resources to a per-workstream temp directory
on skill load, expose via SKILL_RESOURCES_DIR env var and PATH, clean up
on skill change or session close.

* fix: pre-flight validation warns when skill references missing resources

Scan rendered skill content for path references (scripts/foo.py, etc.)
and compare against bundled skill_resources. Warn via on_info if any
referenced paths are not bundled, so operators see the gap at skill
activation rather than at runtime FileNotFoundError.

* fix: address PR #271 review feedback

- Fix trailing colon in PATH when $PATH is empty (cwd-on-PATH risk)
- Move try/except inside per-resource loop so one bad write doesn't
  abort all resources
- Explicit encoding="utf-8" for deterministic writes across locales

* fix: address PR #271 review round 2

- Normalize available paths in _validate_skill_resources() to match
  referenced paths (both sides use os.path.normpath now)
- Fix flaky traversal test: assert inside base dir, not escaped path
2026-03-31 22:34:24 -07:00
Patrick Buckley 200dcfeac5 chore: trivy ignore CVE-2026-4046 (glibc iconv DoS, fix deferred) 2026-03-31 18:01:33 -07:00
Patrick Buckley 8c414feba2 chore: bump version to 0.9.9 2026-03-31 17:45:34 -07:00
Patrick Buckley d7cea053b6 fix: prevent cross-workstream SSE event contamination in WebUI (#270)
Multiple browser tabs open to the same server could see workstream
names, states, and content mixed up between workstreams when creating,
closing, and switching tabs rapidly.

Root causes and fixes:
- Global SSE ws_created events were never handled — other tabs never
  learned about new workstreams, causing blank names and stale tab bars
- SSE reconnection assigned all stale panes to the first workstream
  instead of deduplicating; now uses two-pass assignment with tracking
- switchTab left the old EventSource open while reassigning pane.wsId,
  creating a window for events to leak; now disconnects SSE first
- Per-workstream events carried no ws_id — server now stamps ws_id on
  all events via _enqueue (shallow copy); client handleEvent drops
  events with mismatched ws_id as defense-in-depth
- Plan dialog used pane.wsId at resolve time (could drift after tab
  switch); now captures ws_id when the dialog opens
- Global ws_closed could reassign panes before per-ws SSE finished
  draining; now disconnects per-ws SSE immediately on close
2026-03-31 17:43:25 -07:00
Patrick Buckley c45e98462b fix: prompt policy endpoints used non-existent admin.prompt_policies permission
The 5 prompt policy admin endpoints required "admin.prompt_policies"
but the builtin-admin role only grants "admin.policies". Changed to
match the existing permission used by tool policy endpoints.
2026-03-31 17:43:01 -07:00
Patrick Buckley e17cbe35a5 fix: harden Discord bot against gateway disconnects and SSE failures (#269)
* fix: harden Discord bot against gateway disconnects and SSE failures

- Isolate Discord API failures from SSE stream — _on_ws_event exceptions
  no longer kill the SSE connection and cause missed events
- Fix broken exponential backoff on 4xx/5xx (delay was reset on every
  attempt); skip aiter_sse() on error responses
- Add read timeout (90s) to SSE httpx client so half-open TCP
  connections are detected and recovered
- Re-resolve node URL on each SSE reconnect attempt
- Add on_resumed handler to recover SSE tasks that died during brief
  gateway disconnects (on_ready is not called on session resume)
- Sync slash commands only on first on_ready to avoid Discord rate limits

* fix: SSE backoff on 4xx/5xx and retrieve dead task exceptions

- Replace `continue` with raise+catch so 4xx/5xx errors hit the
  exponential backoff path instead of tight-looping
- Retrieve task exceptions in _purge_dead_sse_tasks to suppress
  "Task exception was never retrieved" warnings and log the cause
2026-03-31 17:28:59 -07:00
Patrick Buckley fd47c23177 chore: bump version to 0.9.8 2026-03-31 16:36:48 -07:00
Patrick Buckley 9fe988b1be fix: bootstrap DATABASE_URL scheme, Docker fd exhaustion, proxy error… (#268)
* fix: bootstrap DATABASE_URL scheme, Docker fd exhaustion, proxy error handling

- Bootstrap system prompt now generates postgresql+psycopg:// URLs
  (required by SQLAlchemy 2.0 + psycopg3)
- Dockerfile splits bytecode compilation into a separate step to avoid
  exhausting file descriptors during uv sync (os error 24)
- Bootstrap OpenAI completion path guards against non-spec responses
  from proxies (Open WebUI, LiteLLM) with actionable error messages

* fix: address Copilot review — unique tool_call IDs, robust compileall path

- Tool call ID fallback uses random hex instead of sequential index
  to avoid cross-turn collisions with non-spec proxies
- compileall targets .venv/ (not .venv/lib/) for layout portability
2026-03-31 16:30:32 -07:00
Patrick Buckley 3ce66960bc feat: modular system message composition with admin prompt policies (… (#267)
* feat: modular system message composition with admin prompt policies (#267)

Replace the monolithic persona+tools block in _init_system_messages()
with a modular composition harness (turnstone/prompts/). System messages
are now assembled from five typed layers: BASE (persona), ENV (client
surface — web/cli/chat), CONTEXT (datetime, timezone, username), TOOLS
(usage patterns), and POLICIES (behavioral rules with tool gating).

Prompt policies are admin-managed via a new Prompts tab in the Governance
group (CRUD with modal forms, tool gating, priority ordering, enable/disable).
DB policies override file-based defaults by name; file-based policies serve
as deployment defaults. Migration 031 adds the prompt_policies table.

ClientType is threaded end-to-end from channel adapters through the SDK,
HTTP API, WorkstreamManager, and session factory to ChatSession. Discord
sessions now receive chat-optimized formatting (no tables, no Mermaid,
concise output) instead of the web UI's rich markdown instructions.

* fix: address CI failures and Copilot review feedback

- Add client_type param to CLI session_factory (mypy protocol match)
- Add prompt policy CRUD to PostgreSQL backend (test-postgres CI)
- Fix ClientType resolution: compare against enum values, not members
- Fix null client_type coercion (body.get returns None, not "")
- Use local time with astimezone() instead of UTC with local tz name
- Sanitize tool_gate in update endpoint (coerce null to empty string)
2026-03-31 15:25:55 -07:00
Patrick Buckley 8a852a12e3 remove poll-interval from compose.yml 2026-03-31 13:46:09 -07:00
Patrick Buckley 9a518657a3 feat: replace console HTTP polling with persistent SSE streams (#266)
* feat: replace console HTTP polling with persistent SSE streams

Console collector now subscribes to each server node's /v1/api/events/global
SSE stream for real-time state updates instead of polling /v1/api/dashboard
and /health every 15 seconds.

Server changes:
- Emit ws_created/ws_closed events on global queue from create/close handlers
- Add node_snapshot on SSE connect (workstreams, health, aggregate)
- Add ?expected_node_id= identity verification (409 on mismatch)
- Add health_changed callback to BackendHealthMonitor circuit breaker
- Add periodic aggregate emitter thread (10s)

Console collector changes:
- Single asyncio event loop on one thread multiplexes all SSE connections
  (scales to 1000+ nodes vs thread-per-node)
- Discovery loop spawns/cancels async SSE tasks per node
- Snapshot reconciliation on connect, delta application for live events
- Fix ws_state→cluster_state event type mismatch
- Remove polling code (poll_interval, max_poll_workers, --poll-interval CLI)

SDK changes:
- Add NodeSnapshotEvent, HealthChangedEvent, AggregateEvent dataclasses
- Add stream_node_events() method (async + sync)

* fix: address review feedback on node event streams

- Fix stop() to let SSE manager exit naturally instead of force-stopping
  the event loop (ensures finally cleanup runs)
- Guard against empty/invalid SSE data from ping frames
- Treat missing node_id as identity mismatch (409) when expected_node_id
  is provided
- Fix stale docstring on _update_metrics
2026-03-31 11:28:39 -07:00
Patrick Buckley c424176c73 feat: show thinking indicators, tool calls, and results in Discord th… (#265)
* feat: show thinking indicators, tool calls, and results in Discord threads

Discord threads now surface real-time activity during multi-tool chains
instead of appearing idle. ThinkingStart/Stop events display a transient
italic status message. ToolInfoEvent sends a per-tool "running" embed
that ToolResultEvent edits in-place with the result (FIFO matching by
tool name, fallback to new message). Includes backtick-injection escaping
in tool output. Visibility respects auto-approve config so tool calls
always appear somewhere.

* fix: address review feedback on Discord action visibility

Delete thinking messages in unsubscribe/stale-route cleanup (not just
pop state). Sanitize tool-call previews (escape backticks, strip
mentions). Fix format_tool_result docstring re ellipsis line count.
Add regression test for triple-backtick escaping.

* fix: disable approval buttons on server-side resolution (timeout)

Handle ApprovalResolvedEvent in _on_ws_event to disable buttons and
grey out the approval embed when the server resolves the approval
externally (timeout, auto-approve from another client). Extract
disable_message_buttons helper from views.py so it works on a plain
Message (not just an Interaction).

* fix: reply with guidance when user DMs the bot directly

Non-reply DMs were silently ignored. Now sends a message directing the
user to /ask or @mention in a server channel.

* fix: address round 2 review feedback

- Show error items (policy-denied) in ToolInfoEvent unconditionally
- Match ToolResultEvent to ToolInfoEvent by call_id (deterministic),
  fall back to name-based FIFO when call_id is absent
- Escape triple backticks before truncating in format_tool_result so
  the 500-char limit holds after expansion

* fix: edit thinking message in-place instead of delete-and-recreate

ThinkingStopEvent now preserves the message for the next event to reuse.
ContentEvent seeds StreamingMessage with the thinking message so the
first flush edits it. ToolInfoEvent edits the thinking message into the
first tool embed. Eliminates the visible delete → gap → new message
flicker during thinking → tool call transitions.

* feat: separate tool call and result into distinct Discord messages

ToolInfoEvent sends a "running" embed (light grey, tool name + preview).
ToolResultEvent marks it "Done"/"Error" (color + title update) and sends
the result as a separate message. This gives clear lifecycle tracing in
chat-style threads where verbosity aids readability.

* fix: show running embed for all tools and remove redundant name prefix

ToolInfoEvent now shows a running embed for every tool regardless of
needs_approval — the running indicator and approval dialog serve
different purposes. Removes the needs_approval/auto_approve filter
that caused missing running embeds when tools were approved via
"Always Approve" or server-side auto-approve.

Also drops the redundant **name** prefix from format_tool_result since
the embed title already carries the tool name.

* fix: concise logging for SSE connection failures

Catch httpx.ConnectError/ConnectTimeout separately from the generic
exception handler. Logs url and error string instead of the full
httpx/httpcore stack trace, which is noise for expected transient
connection failures during node restarts.

* fix: address round 3 review feedback

- Pop _pending_approval_msgs on button click so ApprovalResolvedEvent
  doesn't double-update the embed title (e.g. "Approved - Approved")
- Remove unused name/is_error params from format_tool_result — embed
  title carries the name, embed color carries the error status
- Fix _disable_buttons docstring to mention title update
2026-03-31 09:28:19 -07:00
Patrick Buckley 2c32e89de3 chore: bump version to 0.9.7 2026-03-30 23:24:16 -07:00
Patrick Buckley 06310c74ee fix: channel gateway SSE connectivity and multi-turn messaging (#264)
- Fix missing /v1 prefix on SSE endpoint URL (caused all SSE connections
  to get text/plain 404 responses instead of event streams)
- Stop treating StreamEndEvent as session-terminal (it fires per-segment,
  not per-workstream) so multi-turn conversations work in Discord
- Bail on 404 instead of retrying forever for gone workstreams, and clean
  up stale routes from storage
- Check response status before iterating SSE events to avoid retrying
  non-retryable upstream errors
- Default rebalancer.enabled to True so hash ring routing works without
  manual ConfigStore setup
- Add one-shot cache refresh fallback on route endpoints to handle the
  startup race between rebalancer and first routed request
2026-03-30 23:22:04 -07:00
Patrick Buckley dfad58a3d2 chore: suppress unfixed Debian 13 CVEs in trivy scan
ncurses (CVE-2025-69720), nghttp2 (CVE-2026-27135), systemd
(CVE-2026-29111) — all status "affected" with no fix available in
Debian repos yet.
2026-03-30 23:15:12 -07:00
Patrick Buckley e31197d64a chore: streamline README for clarity and accuracy
- Remove stale "message queues" language from tagline
- Remove duplicated content covered by docs (governance details,
  judge config, config.toml reference, health/rate-limit details,
  monitoring metrics, tool table, multi-model config)
- Replace tool table with summary + link to docs/tools.md
- Add documentation index table linking to all doc pages
- Add architecture summary (single-node vs multi-node routing)
- Add component table for entry points
- Trim diagram table to most useful subset
- Consolidate quickstart section

README is now a concise landing page that directs to docs for
details, not a duplicated reference manual.
2026-03-30 22:34:05 -07:00
Patrick Buckley 5f9200f6a0 fix: UnboundLocalError on TLS advertise URL upgrade
When TURNSTONE_ADVERTISE_URL is set (Docker deployments), the
_advertise_host variable was never assigned. The TLS upgrade path
tried to use it to construct the https:// URL, causing an
UnboundLocalError that made TLS init fail silently.

Fix: derive the TLS URL from _advertise_url (replace http → https)
instead of reconstructing from _advertise_host.
2026-03-30 22:29:52 -07:00
Patrick Buckley 84b0d5615c fix: fail fast when no console or server URL available
Address Copilot PR feedback:
- Exit with clear error if neither console_url nor server_url is
  available after discovery (prevents cryptic failures downstream)
- Fix log field names: console → console_url, server → server_url
  for consistency with other channel log events
2026-03-30 22:17:37 -07:00
Patrick Buckley d41621877f feat: SDK token_factory for auto-rotating service JWTs
Add token_factory parameter to SDK clients (_BaseClient, server,
console) — a Callable[[], str] invoked before each request to get
the current auth token. Supports ServiceTokenManager for auto-rotating
JWTs that re-mint transparently before expiry.

Channel gateway creates dual token managers:
- console-audience JWT for routing proxy calls (via AsyncTurnstoneConsole)
- server-audience JWT for direct SSE connections to server nodes

Both _request() and _stream_sse() inject the factory header per-call,
so long-lived connections get fresh tokens on reconnect.

Also adds TURNSTONE_CONSOLE_URL to console compose service for
DNS-resolvable service discovery.
2026-03-30 22:17:37 -07:00
Patrick Buckley a4f3d205d1 fix: channel gateway service discovery with retry + logging
- Default --server-url is now empty (not localhost:8080) to avoid
  unreachable fallback URLs inside Docker containers
- Auto-discovery retries for up to 30s waiting for console or server
  to register in the services table (handles startup ordering)
- Log discovery progress (discovering, discovered_console, discovered_server)
  and warn on timeout or failure
- Wrap discovery in try/except so storage init failures don't crash startup
2026-03-30 22:17:37 -07:00
Patrick Buckley 6078a88533 fix: channel gateway service discovery with retry
- Default --server-url is now empty (not localhost:8080) to avoid
  unreachable fallback URLs inside Docker containers
- Auto-discovery retries for up to 30s waiting for console or server
  to register in the services table (handles startup ordering)
- Both console_url and server_url are discovered from DB when not
  explicitly set via CLI flags or env vars
2026-03-30 21:29:47 -07:00
Patrick Buckley 843fa04e65 fix: address Copilot PR review feedback
- 404 retry: use blocking lock acquire so retry waits for cache refresh
  to complete instead of skipping on contention
- 404 retry: surface httpx.HTTPError as 502 instead of suppressing it
  and returning the original 404
- channel router: pass auto_approve_tools to create_workstream calls
  (was silently dropped for console-routed creates)
- api-reference.md: document all /v1/api/route/* console routing proxy
  endpoints and console /metrics
2026-03-30 20:30:05 -07:00
Patrick Buckley 473298199d fix: address PR review feedback
- router.route(): validate ws_id length and hex format before bucket
  extraction, raise NoAvailableNodeError instead of ValueError
- router: expose version as public property, collector uses it instead
  of accessing _version directly
- memory.py: deduplicate _bucket_of with canonical bucket_of from
  hash_ring module
- architecture SVG: reroute direct/SSE lines below console to avoid
  crossing over the console box
2026-03-30 20:30:05 -07:00
Patrick Buckley c251e2dac8 fix: console dashboard missing real-time state change events
The collector's _apply_poll only detected workstream additions and
removals (set diff on ws_ids). State changes within existing
workstreams (idle → running, running → attention, etc.) were not
emitted to the SSE stream, so the dashboard only updated on manual
page refresh.

Now _apply_poll compares state and name fields between old and new
poll snapshots and emits ws_state and ws_rename events for any
changes. These flow through _fanout to the browser SSE stream,
giving real-time dashboard updates without page refresh.
2026-03-30 20:30:05 -07:00
Patrick Buckley ac47476d0a fix: server advertise URL in Docker + remove stress cluster
Bug: Server nodes registered with container ID hostnames (e.g.,
http://a236323a92f6:8080) which aren't DNS-resolvable by other
containers. The console collector failed to poll nodes, causing
stale health/error status on the dashboard.

Fix: Add TURNSTONE_ADVERTISE_URL env var support. In compose, each
server sets it to the Docker service name (http://server-1:8080 etc).
Falls back to socket.getfqdn() when not set.

Also: remove the 100-node stress cluster (ddgStressCluster profile)
from compose.yaml. It was 720 lines of boilerplate from the old
simulator era. The simulator is being rebuilt separately (task #5).
Compose goes from 1028 to 304 lines.
2026-03-30 20:30:05 -07:00
Patrick Buckley 055bd5a88f fix: initial_message not processed + channel gateway routing
Bug 1: Server's create_workstream handler ignored initial_message from
the request body. The old bridge sent it as a follow-up SendMessage
via Redis, but with direct HTTP nobody was sending it. Now the server
spawns a worker thread to send the initial message after creation,
matching the bridge's behavior.

Bug 2: Channel gateway compose config used --server-url=http://server:8080
which doesn't exist in cluster/ddgCluster profiles. Removed the hardcoded
URL — the channel gateway auto-discovers the console from the services
table via shared PostgreSQL. Added TURNSTONE_DB_URL and auth token to
the channel environment so DB-based service discovery works.
2026-03-30 20:30:05 -07:00
Patrick Buckley a7d9461735 refactor: channel router + scheduler use SDK clients
ChannelRouter: replace raw httpx with AsyncTurnstoneServer (single-node)
and AsyncTurnstoneConsole route methods (multi-node). Remove _post()
helper, _route_path(), and manual JSON construction.

Scheduler: replace raw httpx.Client with TurnstoneServer (sync). Lazy
per-node client cache with token rotation and stale client pruning.

Clean remaining Redis/MQ references from tests, docs, and config:
- test_tls_admin: redis.internal -> app.internal
- test_config: [redis] test data -> [database]
- docs/channels.md, console.md: rewrite for HTTP architecture
- docs/api-reference.md, openshell.md: remove stale diagram/Redis refs
- turnstone.example.toml: remove [redis] section
- .pre-commit-config.yaml: remove types-redis dependency
- QUICKSTART.md: remove bridge/Redis from deployment descriptions
2026-03-30 20:30:05 -07:00
Patrick Buckley 9de77c3ee3 feat: extend SDK clients for internal dogfooding
Server SDK create_workstream: add initial_message, auto_approve_tools,
user_id, ws_id params (all optional, omitted when empty).

Console SDK: add auto_approve, auto_approve_tools, user_id to
create_workstream. Add 8 route_* methods for the routing proxy path
(/api/route/*): route_create_workstream, route_send, route_approve,
route_plan_feedback, route_close, route_cancel, route_command,
route_lookup. Sync mirrors for all.

Prepares for channel gateway and scheduler to use SDK clients instead
of raw httpx calls.
2026-03-30 20:30:05 -07:00
Patrick Buckley 0cfe521ce7 docs: extract HashRing into reference design document
Move the consistent hash ring implementation (FNV-1a, virtual nodes,
bisect lookup) from code to docs/design/consistent-hash-ring.md as a
forward-looking reference for future scalability work.

The current rebalancer uses weight-proportional distribution (simpler,
exact splits, no hash variance). The ring algorithm is documented with
test vectors, stability properties, and a comparison table for when
the ring approach becomes advantageous (large clusters, decentralized
routing, cross-language determinism).

hash_ring.py retains: RING_SIZE, bucket_of(), RingNode, NoAvailableNodeError
(all actively used by router and rebalancer).
2026-03-30 20:30:05 -07:00
Patrick Buckley c2750de7a4 feat: minimal-transfer rebalancer algorithm
Replace the full-rehash algorithm (diff ideal vs current across all
65536 buckets) with a donor/recipient algorithm that only moves
buckets from overloaded nodes to underloaded nodes.

Key improvements:
- Adding node C to {A, B} only moves buckets TO C, never between
  A and B. Previously the HashRing rehash could shuffle between
  existing nodes.
- Seeding uses weight-proportional distribution instead of HashRing
  virtual nodes, producing an exact split that doesn't trigger
  immediate correction on the next cycle.
- Dead-node buckets are redistributed to the most underloaded
  survivors, not rehashed across the whole ring.
- HashRing class is no longer used by the rebalancer (still
  available for other uses like the Go rewrite reference).

The threshold check still gates live-to-live moves. Dead-node
recovery remains unconditional.
2026-03-30 20:30:05 -07:00
Patrick Buckley bd782f804e feat: add set_bucket_stat + console Prometheus metrics
set_bucket_stat: single-upsert storage method replacing the N-loop
reconciliation in the rebalancer. Reduces DB round-trips from
|ws_delta| per bucket to exactly 1.

Console metrics: /metrics endpoint on the console exposing 6 routing
and ring metrics in Prometheus text format:
- turnstone_router_requests_total (method, status)
- turnstone_router_request_duration_seconds (method)
- turnstone_ring_membership_size
- turnstone_ring_version
- turnstone_ring_rebalance_total (result)
- turnstone_ring_migrations_total

Instrumented in route_create, route_proxy, route_lookup handlers.
Ring gauges updated on collector discovery loop. Rebalance/migration
counters recorded after each rebalancer pass.
2026-03-30 20:30:05 -07:00
Patrick Buckley 87b69a318b feat: implement eager migration in rebalancer
When rebalancer.eager_migrate is enabled, the rebalancer POSTs
/_internal/migrate to source nodes after reassigning buckets,
triggering immediate workstream eviction instead of waiting for
lazy resume on the next request.

Only idle workstreams are eagerly migrated — active ones (running,
thinking, attention) are left alone to avoid disrupting in-flight
work. Failed migrations are logged and skipped (the lazy path
handles them eventually).
2026-03-30 20:30:05 -07:00
Patrick Buckley a315cabe71 chore: polish — remove dead code, update diagrams and docs
Remove stale Redis/Bridge/MQ references found via vulture scan and
manual grep:
- bot.py docstring: remove Redis MQ reference
- server.py trusted_sources: remove "bridge"
- tls.py docstring: remove "bridge" from service list

Delete 4 obsolete diagram pairs (puml + png):
- 06-mq-protocol, 07-message-routing, 08-redis-key-schema,
  10-simulator-architecture

Update 7 diagrams to reflect direct HTTP architecture:
- system-context, package-structure, workstream-states,
  console-data-flow, deployment, channel-architecture,
  settings-architecture

Redraw architecture-overview.svg: Console router replaces Redis MQ,
direct SSE data plane, hash ring routing.
2026-03-30 20:30:05 -07:00
Patrick Buckley be17d8c5d0 feat: add rebalancer daemon, settings, and migrate endpoint
Rebalancer: daemon thread in the console process that maintains
bucket-to-node assignments in hash_ring_buckets. Seeds the ring on
first run (empty table → 65536 rows via consistent hash). Periodically
checks for membership changes and rebalances: moves cheapest buckets
first (empty > idle > active), respects imbalance threshold, reconciles
bucket_stats against actual workstream counts before each pass.

Uses DB-based leader election (rebalancer_lock in system_settings) for
multi-console deployments. Increments rebalancer_version after writes
so console routers refresh their caches.

Add 6 settings: ring.vnodes_per_unit, rebalancer.enabled/interval/
threshold/eager_migrate, node.weight.

Add /_internal/migrate endpoint on server for eager workstream eviction.
2026-03-30 20:30:05 -07:00
Patrick Buckley 62ce450b06 feat: wire console router into server with routing proxy endpoints
Add routing proxy endpoints to the console server:
- POST /v1/api/route/workstreams/new — hash-ring-routed create with
  503 retry, target_node pinning, and node_url injection
- POST /v1/api/route/{send,approve,cancel,command,close} — generic
  proxy to workstream owner via O(1) bucket lookup
- GET /v1/api/route?ws_id=X — node URL lookup for direct SSE

Wire ConsoleRouter into console lifespan (cache refresh on startup)
and collector discovery loop (version-based cache invalidation).

Add --console-url to channel gateway CLI for multi-node routing.
ChannelRouter routes control-plane through console when set, SSE
connections go direct to server nodes via node_url from create response.
2026-03-30 20:30:05 -07:00
Patrick Buckley d19dad05bd feat: add consistent hash ring and console router
HashRing: FNV-1a virtual nodes, immutable, computes ideal bucket-to-node
distribution. Used by the rebalancer (next commit) to seed and maintain
the assignment table.

ConsoleRouter: in-memory flat array of 65536 NodeRef entries loaded from
hash_ring_buckets table. O(1) routing via ws_id prefix. Supports
per-workstream overrides, version-based cache refresh, and targeted
ws_id generation.

Both are pure library code with no server integration yet.
2026-03-30 20:30:05 -07:00
Patrick Buckley 262a6a9918 feat: add hash ring tables and storage protocol (migration 030)
Add three tables for the hash ring routing system:
- hash_ring_buckets: bucket-to-node assignments (65536 rows, rebalancer-managed)
- bucket_stats: per-bucket workstream counts (server-managed lifecycle counters)
- workstream_overrides: per-workstream routing pins (targeted/admin/pinned)

Add 10 storage protocol methods with SQLite and PostgreSQL implementations.
Wire bucket_stats lifecycle hooks into WorkstreamManager create/close/set_state.

Tables start empty — the rebalancer (Phase 3) seeds hash_ring_buckets on
first run. bucket_stats rows are upserted lazily on workstream lifecycle.
2026-03-30 20:30:05 -07:00
Patrick Buckley 2bb55590bf feat: replace Redis MQ with direct HTTP transport (Phase 1)
Delete the entire turnstone/mq/ package (broker, bridge, protocol,
client) and turnstone/sim/ package. Remove Redis as a dependency.

Channel gateway and console now communicate with server nodes via
direct HTTP (httpx + httpx-sse) instead of Redis pub/sub and queues.
Single-node deployments work with zero infrastructure beyond the
database.

Key changes:
- Channel adapters use httpx POST for create/send/approve/close
  and httpx-sse for per-workstream event streaming
- Console collector discovers nodes via services table instead of
  Redis SCAN
- Console scheduler dispatches tasks via HTTP POST with DB-based
  leader election
- Server registers in services table with 30s heartbeat
- Server accepts optional ws_id in create request (for Phase 2
  console-generated routing)
- SDK events gain IntentVerdictEvent and OutputWarningEvent types
- All docs, examples, bootstrap wizard updated

63 files changed, -5968 net lines (Redis transport fully removed)
2026-03-30 20:30:05 -07:00
Patrick Buckley 0e02d1b52c release: v0.9.6 2026-03-30 06:07:44 -07:00
renovate[bot] 9b29453e9b chore(deps): lock file maintenance (#259)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-30 05:58:00 -07:00
Patrick Buckley c4ff1caf09 fix: sync actual TLS state to ConfigStore on console startup (#258)
* fix: sync actual TLS state to ConfigStore on console startup

The console writes tls.enabled to the DB but never clears it when TLS
init fails or isn't configured.  Server nodes read the stale DB value
and attempt TLS negotiation with a non-TLS console, producing noisy
SSL errors on every startup.

Console now syncs the actual TLS state after init: if TLS succeeded,
tls.enabled=true; if it failed or wasn't attempted, tls.enabled=false.
Server TLS failure log reduced from full traceback to one-line warning.

* fix: sync TLS state to ConfigStore on console startup

Console now writes the definitive TLS state to ConfigStore so server
nodes don't attempt TLS against a non-TLS console:

- TLS init succeeded → write true
- TLS not configured (DB false/unset) → write false (definitive)
- TLS configured (DB true) but init failed → don't overwrite
  (transient failure shouldn't permanently disable)

Server TLS warning reduced to one line with exception type, full
traceback available at debug level.
2026-03-30 05:57:36 -07:00
Patrick Buckley 22245145db fix: remove hamburger menu and logout button from server UI header (#256)
Replace with a direct theme toggle button matching the console UI
pattern. Dashboard remains accessible via Ctrl+D.
2026-03-30 05:52:04 -07:00
renovate[bot] 8eacc4d632 chore(deps): lock file maintenance (#257) 2026-03-30 05:51:03 -07:00
Patrick Buckley 23fed785c4 feat: auto-detect model changes when LLM backend swaps models (#255)
* feat: auto-detect model changes when LLM backend swaps models

The BackendHealthMonitor already probes /v1/models every 30s but
discarded the response.  Now compares the detected model against the
last known one and triggers a registry reload when it changes.

- Extract _extract_context_window() helper for reuse across
  detect_model, probe_model_endpoint, and the health monitor
- BackendHealthMonitor: new provider/initial_model/on_model_changed
  params; _check_model_change() fires callback on model swap
- Server: wire _handle_model_change callback that updates cli_model_args
  and calls registry.reload(); guarded by _user_specified_model flag
  so --model overrides are never auto-replaced
- Session: _refresh_model_from_registry() called at top of send();
  two string compares when nothing changed, full re-resolve on swap
- 7 new tests for _extract_context_window and model change detection

* fix: address Copilot review on model re-detection

- server: update cli_model_args only after successful reload (not
  before), add finally block for new_reg.shutdown(), guard against
  cli_model_args not yet initialized
- session: wrap registry lookup in try/except for concurrent reload
  race, reset judge on model change, recompute tool_truncation when
  context_window changes in auto mode
2026-03-30 05:43:53 -07:00
Patrick Buckley 688c27e68a feat: replace generic system prompt with resident engineer persona
Replace "You are an expert software engineer" with a grounded
narrative persona: a resident engineer on a focused team with real
tools, real code, and real consequences.  Sets expectations about
boundaries, judgment calls, and working within constraints.
2026-03-30 05:13:29 -07:00
Patrick Buckley 405baf7cb2 fix: memory list/search cross-workstream scope leak (#253)
* fix: scope-filter memory list/search to current workstream and user

Unscoped memory(action='list') and memory(action='search') returned all
memories across all workstreams. Now applies the same 3-query pattern
(global + current workstream + current user) used by system prompt
injection.

* fix: validate user scope on memory search/list for unauthenticated sessions

Adds _validate_scope guard to search and list prepare paths, matching
save/get/delete. Prevents explicit scope='user' from returning all
user-scoped memories when session is unauthenticated.

* fix: update _get_visible_memories references to _list_visible_memories

* fix: defense-in-depth guard for empty scope_id on search/list

Copilot review: if scope is 'user' or 'workstream' with empty
scope_id, the storage query returns all memories in that scope
across all users/workstreams.  The prepare step already validates
via _validate_scope, but add exec-level guard to reject scoped
queries with empty scope_id as defense-in-depth.
2026-03-30 04:43:49 -07:00
Patrick Buckley 1027c22333 fix: add procps and file to Docker image (#254)
Agents need ps for process inspection and file for identifying file
types.  Both were missing from the slim base image.
2026-03-30 04:30:36 -07:00
Patrick Buckley 381651049b fix: detect context window from vLLM max_model_len field (#252)
* fix: detect context window from vLLM max_model_len field

vLLM exposes the context window as max_model_len on the model object,
not meta.n_ctx_train (llama.cpp format).  Both detect_model() and
probe_model_endpoint() now check max_model_len first, falling back
to meta.n_ctx_train for llama.cpp.  Fixes 32768 fallback on vLLM
servers that report 262144+ token context windows.

* test: add vLLM max_model_len detection tests

Copilot review: new vLLM context window path had no test coverage.
Add tests for probe_model_endpoint (max_model_len detected, preferred
over meta.n_ctx_train) and detect_model (vLLM model object with
max_model_len).
2026-03-30 04:19:24 -07:00
renovate[bot] 322b7dabc4 chore(deps): lock file maintenance (#251)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-30 04:07:28 -07:00
Patrick Buckley d5e86c8493 release: v0.9.5 2026-03-29 23:02:53 -07:00
Patrick Buckley c154ea3966 fix: subscribe to workstream events before sending first Discord message (#250)
The first message sent from Discord was silently dropped because the
cog delegated the initial message to the bridge via CreateWorkstream-
Message, but the bridge published response events to the per-workstream
Redis pub/sub channel before the Discord bot had subscribed to it.
Redis pub/sub is fire-and-forget — events with no subscribers are lost.

Fix: create the workstream with initial_message="" (no delegation),
subscribe to the per-workstream event channel, then send the message
through router.send_message() — the same path the second message
already uses successfully.

Applied to both @mention handler and /ask slash command.
2026-03-29 22:57:56 -07:00
renovate[bot] 755ab51802 chore(deps): lock file maintenance (#249)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-29 22:22:49 -07:00
Patrick Buckley 9df8ab836f Feat/per pane status bar (#248)
* feat: per-workstream status bar above input

Move the global token counter and model name from the header into a
per-pane telemetry strip between messages and the text input. Each
workstream pane now independently shows model name, token usage with
context percentage, tool calls this turn, and turn count.

Backend: add _ws_turn_tool_calls counter (reset per user turn, emitted
in SSE status event alongside turn_count). MQ bridge forwards the new
fields. SDK and TypeScript types updated.

Frontend: build .ws-status-bar DOM in _createDOM, rewrite updateStatus
to target per-pane elements, update SSE connect/disconnect handlers.
Remove #model-name and #status-bar from global header. Restore console
#status-bar CSS in its own stylesheet.

Accessibility: aria-atomic, aria-labels on each field, warning symbols
(▲/⚠) at 80%/95% context for color-blind users, placeholder text
before first status event. Disconnect state uses 2px red border with
dimmed stale fields.

* fix: emit status event on SSE connect so status bar populates on resume

When resuming a workstream, the event_generator only sent connected +
history events. The status bar stayed at placeholder values until the
next LLM response. Now replays session._last_usage as a synthetic
status event right after connected, so token count, tool calls, and
turn count render immediately.

* fix: address Copilot review — remove dead function, clarify locals

Remove updateHeaderForFocusedPane() and its call site (no-op since
status moved per-pane). Rename ambiguous ttc/tc locals to
turn_tool_calls/turn_count in the status replay block.
2026-03-29 22:22:06 -07:00
Patrick Buckley 8d88e6a7eb feat: add memory get action, reduce search/list preview to 200 chars (#247)
* feat: add memory get action, reduce search/list preview to 200 chars

search and list truncated memory content to 500 chars with no way to
read the full value.  Two changes:

- New 'get' action retrieves a single memory by name with complete
  untruncated content.  Searches scopes narrowest-first (workstream
  → user → global).
- search/list previews reduced from 500 to 200 chars now that get
  exists for full content.  Both append a hint:
  "Use memory(action='get', name='...') for full content."

Includes get_structured_memory_by_name wrapper in memory.py and
4 tests.

* Update turnstone/tools/memory.json

* fix: include 'get' in _prepare_memory docstring and invalid-action error
2026-03-29 20:53:48 -07:00
Patrick Buckley cce292f793 fix: strip NUL bytes in both storage backends via shared sanitize_text
PostgreSQL text fields cannot store NUL (0x00) bytes, and SQLite
stores them but they cause downstream issues (API payloads, web UI).
Add sanitize_text() to _utils.py and apply it in both backends'
save_message to content and provider_data fields.
2026-03-29 19:20:42 -07:00
Patrick Buckley 6adc577d30 release: v0.9.4 2026-03-29 18:32:44 -07:00
Patrick Buckley 02c50b81c1 docs: update tool counts, add diff_file docs, new params (#244)
* docs: update tool counts, add diff_file docs, new params

- Tool count 17/18 → 19 across tools.md, architecture.md, and
  PlantUML diagrams (02-package-structure, 05-tool-pipeline)
- Add diff_file tool documentation section
- Document new params: bash timeout + stop_on_error, write_file
  mode (append), edit_file replace_all
- Add diff_file, watch, skill to tool pipeline dispatch table
- Regenerate diagram PNGs

* fix: remove slim dpkg exclusion so man pages are actually installed

The python:3.14-slim image excludes /usr/share/man/* via dpkg config.
man-db was installed but had no pages to serve.  Remove the exclusion
before installing packages, and add manpages package for coreutils
documentation.  Dropped info (rarely used, man covers the same).

* fix: redact DB connection strings and URL-based secrets in output guard

The output redactor missed TURNSTONE_DB_URL and DATABASE_URL because
the env secret key pattern only matched SECRET/TOKEN/PASSWORD/KEY,
not URL-based credential keys.  Also the connection string regex
didn't cover the postgresql+psycopg:// scheme used by psycopg3.

- Add DATABASE_URL, TURNSTONE_DB_URL, DB_URL to explicit env key matches
- Add psycopg and sqlite to connection string scheme pattern

* fix: address Copilot review on docs — tool names, counts, approval

- Fix remaining 17→19 count in tools.md execution pipeline section
- Dispatch table: task→task_agent, plan→plan_agent (match actual names)
- Dispatch table: header clarifies "19 built-in + tool_search"
- watch/skill: show conditional approval (create only / load only)
- Regenerate pipeline diagram PNG
2026-03-29 18:28:21 -07:00
Patrick Buckley 753cd04b4e Fix/orphaned tool results (#243)
* fix: drop orphaned tool_results with no matching tool_use in _convert_messages

The context window increase from 200K to 1M for Claude 4.6 means
conversations that previously triggered auto-compaction now send their
full history.  Older messages with orphaned tool_results (from
pre-fix cancels or compaction boundaries) are now visible to the API,
causing "unexpected tool_use_id in tool_result blocks" errors.

The existing repair code handles orphaned tool_use (synthesizes
missing results), but not the reverse.  Now validates each
tool_result against the preceding assistant message's tool_use IDs
and silently drops results with no match.

* fix: filter empty IDs from prev_tool_use_ids, document pass-through

Code review: empty-ID tool_use blocks were added to the filter set,
and the intentional pass-through when prev_tool_use_ids is empty
needed documentation.
2026-03-29 18:26:25 -07:00
Patrick Buckley c3217748dc fix: block math sandbox escape via getattr/setattr/type reflection (#239)
* fix: block math sandbox escape via getattr/setattr/type reflection

getattr() with runtime-constructed strings bypassed the AST validator,
allowing full os/subprocess access from the sandboxed math tool via
module.__builtins__['__import__']('os').

Three-layer fix:
- Block getattr, setattr, delattr, type, __import__ in
  _MATH_BLOCKED_BUILTINS (prevents direct calls)
- Add AST validation for getattr/setattr/delattr call nodes
  (catches them even if builtins dict is bypassed)
- Strip __builtins__ from all pre-imported modules in the execution
  namespace (runtime defense — even if AST is somehow bypassed,
  module.__builtins__ returns empty dict)

Normal math, sympy, numpy, scipy operations unaffected.

* fix: harden _safe_import to strip __builtins__ from runtime imports

Copilot review: modules imported at runtime via _safe_import still
had their original __builtins__ dict, accessible via
operator.attrgetter('__builtins__').  Now _safe_import strips
__builtins__ from every module it returns.  Also blocks
operator.attrgetter/itemgetter at the AST level, and removes the
redundant duplicate getattr check in visit_Call.

* fix: add type ignore for module __builtins__ assignment
2026-03-29 17:37:59 -07:00
Patrick Buckley 8cbff49694 fix: block /proc/*/environ access in bash filter and judge heuristic (#240)
* fix: block /proc/*/environ access in bash filter and judge heuristic

/proc/1/environ leaks the full server environment including DB
credentials, API keys, and JWT secrets.  Env scrubbing in env.py
only affects subprocess calls, not procfs reads.

- Add /proc/1/environ and /proc/self/environ to BLOCKED_PATTERNS
  in safety.py (hard block)
- Add proc-environ-exfil heuristic rule at critical severity with
  deny recommendation (catches /proc/<pid>/environ patterns)

* fix: move proc-environ-exfil rule to _CRITICAL_RULES list

Copilot review: rule had risk_level=critical but was placed in
_HIGH_RULES.  Move to _CRITICAL_RULES for consistency with the
first-match-wins severity ordering.
2026-03-29 17:37:46 -07:00
Patrick Buckley 4f26d63c14 perf: trim judge context to messages from last user turn onward (#241)
The intent judge was receiving up to 50% of the context window in
conversation history (FIFO from end), which grows linearly with
conversation length and causes increasing latency.  The judge only
needs the immediate request context to evaluate a tool call's safety.

Now trims to messages from the last user message onward before
applying the FIFO budget cap.  Keeps the user's request, the
assistant's response with tool calls, and any recent tool results
while discarding earlier conversation that isn't relevant to the
current intent evaluation.
2026-03-29 17:34:41 -07:00
Patrick Buckley 74347fb29f fix: update Claude 4.6 context windows to 1M, remove EOL 4.0 models (#242)
Claude 4.6 (Opus + Sonnet) unified on 1M token context windows.
Update capabilities table from 200K to 1M for both models.  Remove
claude-opus-4 and claude-sonnet-4 entries (end of life).  4.5 models
remain at 200K.  Default fallback stays at 200K for unknown models.
2026-03-29 17:28:45 -07:00
Patrick Buckley 2ace8cccc8 fix: distinguish user cancel from crash in bash tool results (#235)
* fix: distinguish user cancel from crash in bash tool results

When a user cancels a running bash command, the process is killed
with SIGKILL (exit code -9).  Previously this showed as an error,
causing the model to retry.  Now checks cancel.is_set() after proc
exit and returns "Cancelled by user." as a non-error result so the
model knows to stop rather than retry.

* fix: use -signal.SIGKILL instead of magic -9

Copilot review: replace hard-coded -9 with -signal.SIGKILL for
clarity.  Popen.returncode is negative of signal number when killed.
2026-03-29 17:09:44 -07:00
Patrick Buckley e95b8f5ca1 feat: add stop_on_error param to bash tool for set -e behavior (#236)
* feat: add stop_on_error param to bash tool for set -e behavior

New boolean parameter enables 'set -e' in the bash preamble so
multi-step scripts exit on the first command failure instead of
silently continuing.  Default false (existing behavior preserved).
pipefail remains always-on.

* fix: strict bool parsing for stop_on_error, treat exit 1 as error with set -e

Copilot review: bool("false") is True — use `is True` for strict
JSON boolean parsing.  Also, with stop_on_error enabled, any non-zero
exit code is now treated as an error (set -e means the script halted
on failure), whereas without it exit code 1 remains benign.
2026-03-29 17:09:29 -07:00
Patrick Buckley 7a32c51a1c fix: synthesize cancelled tool results instead of stripping turns (#237)
* fix: synthesize cancelled tool results instead of stripping turns

When a user cancels during tool execution, the model previously lost
all context about what was attempted (assistant message + tool_calls
stripped entirely).  Now synthesizes tool_result messages with
is_error=true and "Cancelled by user." content for any tool_calls
that lack matching results.  This keeps the conversation valid for
both providers while preserving the full tool call structure so the
model knows what was tried.

Also applies to KeyboardInterrupt with "Interrupted by user." text.

* fix: persist synthesized cancel results to DB, assert is_error in test

Copilot review: synthesized tool messages were in-memory only,
creating a mismatch with DB that could break rewind/retry.  Now
calls save_message() for each synthesized result.  Also adds
is_error=True assertion to the cancel test.
2026-03-29 17:09:17 -07:00
Patrick Buckley dce663105b feat: add pagination and longer content to recall tool (#238)
* feat: add pagination and longer content to recall tool

- New offset parameter for paginating through recall results
- Content preview increased from 500 to 2000 chars per match with
  total length indicator when truncated
- Output passed through _truncate_output for consistency
- OFFSET clause added to SQLite (FTS5 + LIKE) and PostgreSQL
  (tsvector + ILIKE) search queries

* fix: defensive int coercion for recall offset/limit

Copilot review: offset/limit could arrive as null, float, or other
non-int types from JSON.  Coerce with int() + try/except in prepare,
and int() at the storage layer before binding into SQL OFFSET/LIMIT.
2026-03-29 17:09:05 -07:00
Patrick Buckley cfef3616e6 feat: add diff_file tool for comparing files and content (#234)
* feat: add diff_file tool for comparing files and content

New read-only tool that shows unified diffs between two files or
between a file and provided content.  Useful for verifying edit_file
changes and comparing file versions.  Auto-approved (no side effects).
Configurable context lines (default 3).  Available to task agents.

* refactor: extract _read_text_lines helper, share across read_file and diff_file

Copilot review: diff_file duplicated file-loading and lacked binary
detection.  Extract _read_text_lines() that handles realpath
resolution, null-byte binary detection, and error handling.  Used by
both _exec_read_file and _exec_diff for consistent behavior.

* fix: address code review — agent flag, resolved shadowing, read_files

- Add agent: true to diff_file schema so plan agents can use it
- Fix resolved variable shadowing in _exec_read_file (use _ for
  unused return from _read_text_lines)
- Register diffed files in _read_files so edit_file read guard
  is satisfied after diff_file
- Move difflib import to module level (stdlib, no lazy-load needed)
- Fix description wording ("provided string" not "previous version")

* fix: stream diff with early cutoff, expand paths before header

- Stream difflib output and stop collecting after tool_truncation
  chars to avoid large intermediate allocations on big diffs
- Expand paths with expanduser before building the approval header
  so display matches actual execution paths
2026-03-29 16:17:48 -07:00
Patrick Buckley c22d39a798 docs: tool descriptions, bash timeout param, multi-line preview (#233)
* docs: tool descriptions, bash timeout param, multi-line preview

- task_agent/plan_agent: document the tool subset limitation (no
  memory, recall, watch, skill, or further delegation)
- bash: add per-call timeout parameter (1-600s, defaults to 120s),
  shown in approval header when specified
- bash: show full command in preview for multi-line scripts so the
  approval flow displays the complete command, not just the first line
- bash: document 256KB output cap and stderr prefix in description

* fix: address Copilot review on tool descriptions

- bash: say "truncated" not "256KB" (limit is configurable), document
  timeout clamping range (1-600) and global fallback
- bash preview: fix "1 more lines" → "1 more line" singular
- plan_agent: remove bash from listed tools (not in AGENT_TOOLS)
2026-03-29 16:17:35 -07:00
Patrick Buckley 63921450b1 fix: improve memory save error message, narrow dd command filter (#232)
* fix: improve memory save error message, narrow dd command filter

Two minor fixes from harness shakedown:

- memory save: split "both name and content required" into separate
  errors for missing name vs empty content
- bash safety: replace blanket "dd if=" block with targeted patterns
  for writes to block devices (of=/dev/sd*, /dev/nvme*, /dev/disk/,
  etc.) and redirects to the same.  Legitimate dd use like generating
  test data or benchmarking reads is no longer blocked.

* fix: generalize > /dev/sda redirect pattern to > /dev/sd

Copilot review: only /dev/sda was blocked for redirects while
/dev/sdb, /dev/sdc etc were not.  Generalize to match any /dev/sd*
device, consistent with the of= patterns.
2026-03-29 16:17:22 -07:00
Patrick Buckley 929fad63be feat: edit_file replace_all, write_file append mode, search match count (#231)
* feat: edit_file replace_all, write_file append mode, search match count

Three tool enhancements from harness shakedown feedback:

- edit_file: new replace_all parameter replaces all occurrences of
  old_string instead of requiring a unique match.  Cannot combine with
  near_line or edits array.
- write_file: new mode parameter with "append" option.  Appends
  content to end of file instead of truncating.
- search: output now includes a summary footer showing total match
  count and file count (e.g. "47 matches across 12 files").

* fix: address Copilot review on tool enhancements

- replace_all: skip multi-occurrence rejection in pre-validation so
  the feature actually works; show occurrence count in preview
- write_file mode: coerce non-string types safely via str()
- search footer: append before truncation to respect output limits
- edit_file error: mention replace_all as alternative to near_line
2026-03-29 16:17:11 -07:00
Patrick Buckley 976e9df3b6 ci: suppress CVE-2026-25210 (libexpat1, no fix available) (#230)
Integer overflow in libexpat1 2.7.1-2 with no patched version in
Debian repos yet.  Suppress in Trivy until a fix is published.
2026-03-29 15:35:20 -07:00
Patrick Buckley 7cb21b84f1 fix: detect binary files in read_file instead of silent corruption (#227)
read_file silently converted null bytes to spaces, showing corrupted
content with no warning.  Now samples the first 8KB for null bytes and
returns a clear error directing the user to bash for binary inspection.
2026-03-29 15:32:04 -07:00
Patrick Buckley 7263edd48d fix: memory delete searches all scopes when scope not specified (#228)
* fix: memory delete searches all scopes when scope not specified

Previously delete defaulted to scope=global, so deleting a
workstream-scoped memory without explicitly passing scope=workstream
silently failed.  Now tries narrowest scope first (workstream → user
→ global) and deletes the first match.  Explicit scope still honored
when provided.

* fix: reject invalid scope on memory delete instead of silent fallback

Copilot review: invalid scope values were silently treated as
unspecified, which could cause accidental deletion from the wrong
scope.  Now returns a clear error listing valid scopes.
2026-03-29 15:29:14 -07:00
Patrick Buckley 6742c7e405 fix: exclude build/vendor/VCS directories from search tool (#226)
* fix: exclude build/vendor/VCS directories from search tool

grep -rn recursed into .git, node_modules, target, __pycache__, etc.
producing hundreds of noise hits from generated content.  Add
--exclude-dir flags for common directories that should never appear
in search results.

* fix: glob egg-info pattern and add vendor exclude

Copilot review: .egg-info misses turnstone.egg-info (named dirs),
use *.egg-info glob.  Also add vendor to the exclude list.
2026-03-29 15:28:52 -07:00
Patrick Buckley 1aa6982868 fix: add git, curl, jq, man-db, info to Docker image (#225)
Agent workflows need git for version control, curl for raw HTTP
requests, jq for JSON processing, and man/info for documentation
lookup.  All were missing from the slim base image, leaving the man
tool non-functional and standard dev workflows broken.
2026-03-29 15:28:38 -07:00
Patrick Buckley 42d1abbd04 fix: block IPv6 loopback/link-local/private in SSRF filter (#224)
* fix: block IPv6 loopback/link-local/private in SSRF filter

check_ssrf used gethostbyname which only resolves IPv4.  IPv6 addresses
like ::1, fe80::, fd00:: bypassed the filter entirely.  Switch to
getaddrinfo which resolves both address families and check all results.

* fix: handle IPv4-mapped IPv6 and zone IDs in SSRF filter

Copilot review caught two bypasses: ::ffff:127.0.0.1 (IPv4-mapped
IPv6) wasn't normalized before private/loopback checks, and fe80::1%lo0
(zone ID suffix) caused a ValueError that was silently swallowed.
Now normalizes IPv4-mapped addresses and strips zone IDs before parsing.
2026-03-29 15:28:27 -07:00
Patrick Buckley f543ed714a fix: resolve symlinks before file I/O to prevent path-based bypass (#223)
* fix: resolve symlinks before file I/O to prevent path-based bypass

write_file and edit_file followed symlinks silently — a symlink at
/data/link → /etc/passwd would show the /data path in the approval
header while writing to the real target.  Three changes:

- open() calls in _exec_write_file, _exec_edit_file, _exec_read_file
  now use the resolved (realpath) path instead of the raw symlink
- Approval headers show both paths when a symlink is detected
  (e.g. "⚙ write_file: /data/link → /etc/passwd")
- Judge _get_arg_text includes the resolved path so heuristic rules
  like write-system-path fire even through symlinks

* fix: address Copilot review — expanduser in fallback, pre-read, image paths

- edit_file exec fallback: add expanduser before realpath (tilde bypass)
- judge _get_arg_text: compare resolved against abspath(expanduser(path))
  so ~/ paths don't false-positive as symlinks
- edit_file pre-read: use resolved path instead of raw symlink path
- _exec_read_image: use resolved path for getsize and binary open
2026-03-29 15:28:13 -07:00
Patrick Buckley 2c6abb0fde fix: clear dedup sigs after write tools to avoid false repeat warnings (#229)
* fix: clear dedup sigs after write tools to avoid false repeat warnings

The read→edit→read workflow triggered "identical repeat" warnings
because the dedup tracker compared (tool_name, args) without
considering intervening state changes.  Now clears the signature set
when write_file, edit_file, or bash executes successfully, so
subsequent reads of the same file are not flagged.

* fix: use shared error prefixes for write-success detection in dedup

Copilot review: the error detection for write tools only checked
"Error" prefix, missing "Command timed out", "Blocked:", "Denied",
etc.  Now shares the same _error_prefixes tuple used by the repeat
detection below, ensuring consistent classification.
2026-03-29 15:27:59 -07:00
Patrick Buckley 491fc6748a fix: judge double tool conversion on Anthropic (#222)
The judge pre-converted tool schemas via convert_tools() before
passing them to create_completion(), which internally calls
convert_tools() again. The second conversion tried to extract
function.name from already-converted Anthropic-format tools,
producing empty tool names that the API rejected with
"tools.0.custom.name: String should have at least 1 character".

Fix: pass raw OpenAI-format schemas directly — create_completion
handles the provider-specific conversion.
2026-03-29 14:51:11 -07:00
Patrick Buckley 9a996f0067 release: v0.9.3
- fix: orphaned tool_use followup — ordering, empty IDs, provider_content bypass, universal repair (#220)
- feat: /retry and /rewind commands, message action controls in web UI (#221)
- docs: update tools, architecture, SDK for v0.9.2 changes
2026-03-29 14:19:02 -07:00
Patrick Buckley a465ac6383 Feat/rewind retry (#221)
* feat: add /retry and /rewind commands for conversation history navigation

Allow users to re-send the last message for a new response (/retry) or
drop the last N turns to restore an earlier conversation state (/rewind N).
Both operations sync in-memory state with the persistent database.

Server path includes conversation.modify permission gate, audit trail
(conversation.rewind / conversation.retry events), and thread-safe retry
dispatch. Migration 029 grants the permission to admin and operator roles.

* feat: add message action controls for retry, edit, and rewind in web UI

Hover toolbar on messages with CSS-only icons matching instrument panel
aesthetic. User messages get edit (pencil) and rewind (chevrons) buttons;
last assistant message gets retry (circular arrow). Edit flow uses
event-driven coordination — rewind completes via SSE history event before
send fires. Includes ARIA labels, keyboard nav, touch device support,
reduced motion, and busy-state gating.
2026-03-29 14:18:39 -07:00
Patrick Buckley a4539923e4 fix: orphaned tool_use followup — ordering, empty IDs, universal repair (#220)
Addresses Copilot review feedback on #219:

1. Anthropic _convert_messages: collect tool_use IDs in order (list
   not set), filter empty IDs, defer synthetic results until after
   real tool results so _merge_consecutive produces correct ordering.

2. Universal repair in reconstruct_messages: synthesize tool results
   for mid-conversation orphaned tool calls on DB load. Benefits all
   providers (OpenAI is lenient today but may tighten).

3. Test improvements: assert on is_error flag instead of "cancelled"
   substring, verify real-before-synthetic ordering in partial results.
2026-03-29 13:33:58 -07:00
Patrick Buckley 42e99d6990 docs: update tools, architecture, SDK for v0.9.2 changes
- docs/tools.md: batch edit_file (edits array), bash stderr prefix,
  math sandbox extras, output truncation
- docs/judge.md: JSON secret detection in output guard
- docs/architecture.md: state_change now sent to per-workstream SSE
- README.md: [sandbox] extras group in requirements
- TypeScript SDK: StateChangeEvent type, type guard, exports
- OpenAPI specs regenerated
2026-03-29 06:06:30 -07:00
Patrick Buckley a0ff22e137 release: v0.9.2
- fix: UI busy state during multi-tool-call turns (#216)
- feat: batch edit_file, sandbox packages, stderr labels, JSON secret redaction, model resume (#217)
- fix: Anthropic sub-agent streaming timeout (#218)
- fix: synthesize tool_results for orphaned tool_use blocks on Anthropic (#219)
2026-03-29 05:56:40 -07:00
Patrick Buckley de4b3b3909 fix: synthesize tool_results for orphaned tool_use blocks on Anthropic (#219)
When a cancel interrupts tool execution, the assistant message with
tool_use blocks is saved to DB before tools run, but
GenerationCancelled prevents tool results from being created. The
in-memory rollback removes the orphaned message, but the DB row
persists. On resume, Anthropic rejects the conversation with
"tool_use ids were found without tool_result blocks".

Fix: _convert_messages now peeks ahead after each assistant message
with tool_use blocks. If any tool_use IDs lack matching tool_result
messages, synthetic error results are injected (is_error: true,
"Tool execution was cancelled."). Transparent to all callers,
provider-specific (OpenAI is lenient about this).

5 new tests covering: single orphan, multiple orphans, partial
results, complete results (no synthesis), and trailing orphan.
2026-03-29 05:54:19 -07:00
Patrick Buckley 120d229b5f fix: Anthropic sub-agent streaming timeout (#218)
plan_agent and task_agent fail on Anthropic models with "Streaming is
required for operations that may take longer than 10 minutes" from
the SDK. The non-streaming create_completion path used
client.messages.create() which the SDK rejects for thinking-enabled
models.

Fix: use client.messages.stream() internally and call
get_final_message() to get the same Message object. Transparent to
all callers — fixes sub-agents, title generation, summarization,
web fetch, and judge create_completion calls.
2026-03-29 05:35:27 -07:00
Patrick Buckley c6f4c11870 feat: harness quick wins — batch edit_file, sandbox packages, stderr labels, JSON secret redaction, model resume (#217)
Five improvements from Opus self-evaluation of the turnstone harness:

1. Batch edit_file: edits array parameter for atomic multi-edit in a
   single tool call. Overlap detection, reverse-order application,
   mutual exclusivity with single-edit params.

2. Sandbox packages: new [sandbox] extras group with sympy, numpy,
   scipy, pytest — the sandbox already had graceful ImportError
   fallbacks, now the packages are actually installed.

3. Stderr labeling: bash tool output prefixes stderr lines with
   [stderr] so the model can distinguish errors from stdout.

4. JSON secret redaction: output guard now detects and redacts secrets
   in JSON format ("api_key": "...", "password": "...", etc.) with
   18 key patterns and 8-char minimum value length.

5. Model persisted on resume: workstream config now saves model and
   model_alias. Resume restores the original model via registry
   (same path as /model command), falling back to raw model name
   if the alias is no longer available.

24 new tests (23 in test_edit_file.py, 1 in test_sessions.py).
2026-03-29 05:21:08 -07:00
Patrick Buckley 979fab37a9 fix: UI busy state during multi-tool-call turns (#216)
stream_end fires per-segment (between tool calls), not per-turn.
The UI was using stream_end to transition to idle, causing a window
where the Send button appeared but the server worker thread was still
alive. User messages submitted during this window were silently
dropped. No Stop button was visible, so the user had no cancel path.

Root cause: state_change events (idle/thinking/running/error) were
only broadcast to the global SSE stream (console dashboard), never
to the per-workstream SSE that the browser UI listens to.

Fix: (1) server.py: on_state_change now also enqueues to the
per-workstream SSE listeners. (2) app.js: stream_end no longer
calls setBusy(false) — it only finalizes markdown rendering.
New state_change handler manages busy transitions: idle/error
set busy=false, thinking/running set busy=true.
2026-03-29 05:20:47 -07:00
Patrick Buckley da5bf90a4b feat: model detect button, capabilities API, and model dropdowns (#215)
* feat: model detect button, capabilities API, and model dropdowns

Admin Models tab: add Detect button that probes a model endpoint to
verify reachability, list available models, detect context_window, and
identify server type (llama.cpp/vLLM/SGLang/OpenAI/Anthropic). Add
static capability lookup endpoint for auto-filling form fields when
a known model name is entered. Add known-models endpoint for datalist
autocomplete suggestions.

Add "openai-compatible" as a third provider option for local servers,
keeping the OpenAI SDK under the hood but suppressing capability
auto-fill and known-model suggestions.

Replace free-text model input with a select dropdown in both console
and server new-workstream modals, populated from a new lightweight
GET /v1/api/models endpoint.

New endpoints:
- POST /v1/api/admin/model-definitions/detect
- GET /v1/api/admin/model-capabilities
- GET /v1/api/admin/model-capabilities/known
- GET /v1/api/models (both console and server)

* fix: accumulate signature_delta for Anthropic thinking blocks (#214)

The streaming path captured thinking_delta events but not
signature_delta, leaving the signature empty on round-trip and
causing 400 errors on multi-turn conversations with thinking enabled.

* fix: address PR review — empty base_url, capability leak, response schemas

- Don't pass empty base_url to OpenAI client (falls back to SDK default)
- Return None from lookup_model_capabilities for openai-compatible provider
- Only use static capability table for known models in _detect_openai_compat,
  avoiding misleading 200k default for unknown local models
- Add AvailableModelInfo + ListAvailableModelsResponse schemas to both
  console_spec and server_spec
- Regenerate TypeScript SDK OpenAPI snapshots

* fix: apply same known-model guard to Anthropic context_window detection

Only report context_window from the static capability table when the
Anthropic model is actually known, matching the OpenAI path fix.

* ui: add autocomplete hint to Model ID label in admin modal

* fix: use explicit kwargs for OpenAI() to satisfy strict mypy
2026-03-29 03:50:21 -07:00
Patrick Buckley 70c18467cb fix: accumulate signature_delta for Anthropic thinking blocks (#214)
The streaming path captured thinking_delta events but not
signature_delta, leaving the signature empty on round-trip and
causing 400 errors on multi-turn conversations with thinking enabled.
2026-03-29 03:10:49 -07:00
Patrick Buckley 801774bc4a fix: add diagnostic logging for silent tool call drops (#213)
When a local model generates tool calls that are silently dropped
(truncation, missing tool-call-parser), there was zero server-side
logging — making it impossible to diagnose from docker compose logs.

- OpenAI provider: log request params and response summary (debug)
- Session: log stream completion, tool call presence (info), and
  tool call discard with names when truncated (warning)

CLI unaffected — log level is WARNING there.
2026-03-29 02:25:43 -07:00
Patrick Buckley 497984b452 feat: database-backed model definitions with admin UI (#212)
Add model_definitions table (migration 028) enabling model management
via the admin console without SSH access or server restarts. Models
defined in the database coexist with config.toml models through a
per-node merge strategy — config.toml overrides DB for the same alias,
DB-only models coexist alongside, no cross-node contamination.

Storage layer:
- model_definitions table with CRUD (SQLite + PostgreSQL)
- MODEL_DEFINITION_MUTABLE allowlist, admin.models permission

ModelRegistry integration:
- load_model_registry() merges DB + config.toml + CLI models
- context_window=0 auto-detects from provider capability table
or inherits CLI-detected value (same as config.toml behavior)
- ModelRegistry.reload() with validation, TOCTOU-safe accessors
- internal_model_reload + internal_model_status server endpoints

Admin API + UI:
- 6 console endpoints (list, create, get, update, delete, reload)
with admin.models permission, audit trail, provider validation
- Models tab in System group with sky blue (--blue) accent color
- Provider badges (openai/anthropic), source badges (config/db)
- Write-only API keys (never readable, "***" sentinel on update)
- Sync-pending indicator, mobile responsive, focus-trapped modal

Also changes is_secret settings from write-blocked (403) to write-only
across all settings, making judge.api_key configurable via admin UI.
2026-03-29 01:58:08 -07:00
Patrick Buckley bdc1eba34c cleanup: drop vestigial tool_args column from conversations (migration 027) 2026-03-28 23:36:49 -07:00
Patrick Buckley 028c77cae5 fix: display tool errors inline in CLI (#210)
* fix: display tool errors inline in CLI

* fix: thread-safe stderr write with _print_lock and flush
2026-03-28 23:08:24 -07:00
Patrick Buckley 76d007d83f fix: TypeScript SDK DeleteSettingResponse type drift (#209)
* fix: TypeScript SDK DeleteSettingResponse type drift

* fix: export DeleteSettingResponse from SDK index
2026-03-28 23:08:09 -07:00
Patrick Buckley 3f432b8a42 fix: watch dispatch error handler missing stream_end and state cleanup (#208)
* fix: watch dispatch error handler missing stream_end and state cleanup

The watch dispatch run() closure was missing GenerationCancelled
handling, stream_end emission, on_state_change calls, and the
worker_thread identity guard that the send_message path has. This
left the web UI in a stale state when watch-dispatched sends failed.

* fix: address review feedback - on_stream_end, put_nowait, ws._lock, tests

* fix: ruff lint (unused pytest import)

* fix: send_message() use on_stream_end() instead of raw _enqueue
2026-03-28 23:07:47 -07:00
Patrick Buckley f74aa2264e refactor: add is_error to on_tool_result protocol, remove text heuris… (#207)
* refactor: add is_error to on_tool_result protocol, remove text heuristics

Add is_error keyword arg to SessionUI.on_tool_result() so tools
report errors structurally. Server and JS client no longer guess
from output text prefixes — each tool sets the flag at the source.

Bash tool: exit code >= 2 is error, exit code 1 is ambiguous (grep
no-match). History reconstruction keeps text heuristic as fallback
for pre-migration data.

Update SDKs (Python + TypeScript), test mocks, docs, and diagrams.

* fix: infinite recursion in _report_tool_result, signal exits, stale docs

* fix: add _tool_error_flags to test_load_skill ChatSession stubs
2026-03-28 22:09:52 -07:00
Patrick Buckley d00aae2429 fix: tool UX improvements (bash exit codes, previews, edit guard) (#206)
* fix: tool UX improvements (bash exit codes, previews, edit guard)

- Enable pipefail in bash tool so piped commands surface real exit codes
- Move exit code append before UI callback so web UI shows failures
- Remove preview truncation from edit_file, write_file, and math tools
- Add no-op guard to edit_file when old_string == new_string
- Fix collapsed tool output scroll — "click to expand" stays anchored

* fix: correct stale comment on edit_file preview

* fix: suggest re-reading file when edit_file old_string not found
2026-03-28 21:24:23 -07:00
Patrick Buckley 5e09940745 bump version to 0.9.1 2026-03-28 20:32:20 -07:00
renovate[bot] 72bd62d3d8 chore(deps): update dependency katex to v0.16.44 (#204)
* chore(deps): update dependency katex to v0.16.44

* 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-03-28 20:31:00 -07:00
Patrick Buckley d31f89b2e3 ci: let Renovate rebase over github-actions[bot] commits 2026-03-28 20:30:23 -07:00
Patrick Buckley 9bae8f1a10 ci: auto-download vendored JS files on Renovate PRs
Add vendor-js workflow that triggers on Renovate PRs touching
pyproject.toml — detects version changes, runs update-vendored-js.sh,
and commits the actual files back to the PR branch.  Supports manual
dispatch via pr_number input for one-off runs.
2026-03-28 20:28:06 -07:00
renovate[bot] a012561195 chore(deps): lock file maintenance (#205)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-28 20:24:53 -07:00
Patrick Buckley 4198b59a0f fix: eager cancel_ref registration, SDK type drift, force-cancel tests (#203)
Providers now register the SDK stream handle eagerly (before returning
the iterator) instead of lazily inside a generator body. This closes
the window where cancel() couldn't abort a blocked HTTP read because
the stream handle wasn't populated yet.

- OpenAI: yield from → return (HTTP call + cancel_ref happen eagerly)
- Anthropic: split into eager __enter__ + _iter_with_cleanup generator
  with defensive __exit__ on __enter__ failure
- Console SDK: delete_setting() return type fixed from StatusResponse
  to DeleteSettingResponse (matching actual endpoint response)
- 2 new threaded force-cancel integration tests verifying orphaned
  threads don't mutate messages and new generations succeed
- Updated _CancelRef docstring, test robustness (assert on wait)
2026-03-28 20:05:57 -07:00
Patrick Buckley 4f6ef13ce9 fix: cancel button race condition with stream abort and force cancel (#202)
The cancel endpoint emitted a 'cancelled' SSE event before the worker
thread terminated. The frontend transitioned to "send" mode prematurely,
so the next send got rejected with "Already processing a request."

Backend:
- Providers expose SDK stream handle via cancel_ref parameter so
  cancel() can close the HTTP connection and unblock iteration
- Generation counter prevents orphaned threads from mutating messages
  or clearing cancel state after force cancel
- _check_cancelled() added between retry attempts in _try_stream
- Server polls (async, non-blocking) for cancelled worker to exit
- Force cancel (force:true) abandons stuck worker, keeps cancel event
  set so subprocesses are killed, guards against spurious SSE events

Frontend:
- 'cancelled' shows "Cancelling..." then escalates to "Force Stop"
  after 2s for a harder cancel that abandons the worker immediately
- 10s safety timeout auto-recovers if stream_end never arrives
- busy_error re-enables stop button instead of showing send
- Timeout cleanup in disconnectSSE, stream_end, and force .then()
- Layout shift prevention (min-width, white-space: nowrap)
- aria-label updates for accessibility

Tests:
- 7 new tests: stream close, error suppression, cancel_ref population,
  transport error conversion, non-cancel exception propagation, retry
  cancellation check
2026-03-28 19:26:37 -07:00
Patrick Buckley 52716ed611 feat: detect repeated tool calls and nudge model to try different approach (#201)
When a model calls the same tool with identical arguments as a previous
call, append a warning to the tool result and inject a metacognitive
nudge. This breaks loops where small local models get stuck repeating
the same action (e.g. running the same bash command 3+ times).

The repeat signature set is cleared after a warning fires, giving the
model a clean slate. Also cleared on conversation compaction.

Ref: #186
2026-03-28 16:18:12 -07:00
Patrick Buckley 6c9a7d7351 fix: harden tool call handling for local model servers (#200)
* fix: harden tool call handling for local model servers

Local models (Qwen 3.5 9B, etc.) via llama.cpp produce tool calls with
empty IDs, whitespace-padded names, and malformed JSON arguments. These
defensive gaps caused cascading conversation corruption and silent
failures.

- Strip whitespace from tool names in both main and agent paths
- Generate synthetic UUIDs when tool call IDs are empty/null
- Surface malformed tool call errors to the user via on_error
- Give the model actionable hints (expected JSON format, available tools)
  so it can self-correct on retry
- Surface metacognition nudge types to UI via on_info

Ref: #186, #117

* refactor: extract _ensure_tool_call_ids helper, include MCP tools in error

Address Copilot review feedback on PR #200:
- Extract duplicated ID fixup into _ensure_tool_call_ids() static method
- Tests now exercise the actual helper instead of reimplementing the logic
- Unknown tool error now includes MCP tool names alongside builtins
2026-03-28 15:48:11 -07:00
Patrick Buckley 3518f7953c fix: prevent 100% CPU spin from unreachable HTTP MCP servers (#199)
When an HTTP MCP server is unreachable and TCP connect fails immediately
(ECONNREFUSED, DNS failure), the anyio task group inside
streamablehttp_client produces a CancelledError that escapes
asyncio.wait_for and leaves orphaned cancel-scope tasks in an infinite
_deliver_cancellation loop (~800K callbacks/sec).

Three-part fix:
- TCP pre-flight probe (5s timeout) before entering the anyio transport
  context — fails fast on unreachable servers, avoiding the bug entirely
- Catch CancelledError in _connect_one with current_task().cancelling()
  check to distinguish stray anyio cancels from real shutdown
- _safe_close_stack helper with bounded timeout that never raises,
  preventing cleanup errors from masking the original exception
2026-03-28 15:19:04 -07:00
Patrick Buckley 48769e5a97 fix: gate read_resource and use_prompt tools on MCP server availability (#198)
These built-in tools were always sent to the LLM even when no MCP
servers were connected, wasting model turns on calls that would always
return errors. Now gated per-request in _get_active_tools() — same
pattern as the existing web_search gating — using resource_count and
prompt_count for granular filtering.
2026-03-28 14:38:53 -07:00
Patrick Buckley adb4ff6399 fix: resolve pre-existing test failures, stale type ignores, and warnings (#197)
- TLS: suppress no-any-return + unused-ignore on lacme mtls helpers
  (lacme stubs typed locally but not in CI)
- TLS: catch duplicate Prometheus metric registration specifically
  (not blanket ValueError) so real setup errors still propagate
- Discord: add missing storage=None to _make_bot mock (policy evaluation
  path accesses self.storage)
- Web search: patch _ddg_available in gating test so ddgs availability
  doesn't mask the Tavily-only test path
- Bridge stress: close real httpx client before replacing with mock so
  daemon threads don't make real HTTP calls or leak connections
- README: comment out tavily_key placeholder in example config
2026-03-28 14:24:53 -07:00
Patrick Buckley 131a1ec943 fix: stream tool errors in real-time with visual error indicator (#196)
* fix: stream tool errors in real-time with visual error indicator

Tool executors that hit errors (file not found, timeout, write failure,
etc.) were returning error strings without calling ui.on_tool_result(),
so no SSE event was emitted to the browser during streaming. Errors only
appeared after page refresh via history rebuild. Now all error paths
call on_tool_result() so errors stream in real-time.

Added visual error state: tool blocks with errors get a red left border,
red "✗ error" badge, and red error text — matching the existing denied
state pattern but distinct from it. Error detection uses prefix matching
("Error", "Command timed out", "Search timed out") both server-side
(is_error flag on SSE event + _build_history) and client-side (regex
fallback).

Affected tools: bash, read_file, write_file, edit_file, search, math,
man, memory, web_fetch, web_search, plus the run_one catch-all and
prepare-error paths.

* review: expand error prefix detection per copilot feedback

Add Unknown tool, JSON parse error, MCP prompt timed out, and MCP
prompt error to the is_error prefix list in on_tool_result, _build_history,
and the frontend regex. These error messages were confirmed in the
codebase but missing from the detection heuristic.
2026-03-28 00:21:11 -07:00
Patrick Buckley 1cded9b430 chore: bump version to 0.9.0 2026-03-27 21:24:49 -07:00
Patrick Buckley 62c741eb8a fix: prevent assistant messages with content=None from reaching OpenAI API (#195)
Replayed or cancelled conversations could produce assistant messages
with content=None and no tool_calls, which OpenAI-compatible APIs
reject with a 400.  Fix at three layers for defense in depth:

- session.py: use empty string instead of None when building assistant
  messages (streaming + cancellation paths)
- _utils.py: normalise content on DB load in reconstruct_messages()
- _openai.py: add _sanitize_messages() catch-all at provider boundary

Closes #194
2026-03-27 21:22:39 -07:00
Patrick Buckley 3362917e1e chore(deps): update vendored KaTeX 0.16.42 → 0.16.43 (#193) 2026-03-27 10:54:21 -07:00
renovate[bot] 698cbbf988 chore(deps): lock file maintenance (#192)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-26 20:55:46 -07:00
renovate[bot] e47a08b7bc chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.11.2 (#191)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-26 20:55:00 -07:00
renovate[bot] aaa427debd chore(deps): update dependency vitest to v4.1.2 (#190)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-26 20:54:58 -07:00
renovate[bot] 611af76971 chore(deps): pin dependencies (#188)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-26 20:54:49 -07:00
Patrick Buckley bbe28ecab3 fix: cancel LLM judge daemon when user approves/denies tools (#187)
When the LLM judge is enabled and the user makes rapid approval
decisions, judge daemon threads pile up competing for the inference
server, causing timeouts. Pass a threading.Event from session to the
judge — set on approval — so the daemon abandons remaining work
within ~1s, including shutting down the executor to kill in-flight
API calls.
2026-03-26 18:10:23 -07:00
Patrick Buckley 93a9fd3c28 bump: v0.8.9 — mTLS + ACME integration via lacme 2026-03-26 14:33:11 -07:00
Patrick Buckley 62ff3217d0 fix: TLS Docker end-to-end testing fixes (#185)
* fix: TLS Docker end-to-end testing fixes

Fixes discovered during Docker Compose TLS integration testing:

- Dockerfile: use --extra all (prevents missing optional deps)
- lacme 1.0.3: fixes CACertificateIssued event logging crash
- chmod PermissionError: guard for Docker volume mounts
- socket import: moved to top of main() (was inside TLS conditional,
  caused NameError in _default_node_id)
- redis.SSLConnection: ConnectionPool needs explicit connection_class,
  not ssl=True (which only works on Redis() directly)
- Empty redis password: pass None instead of "" to avoid AUTH error
- TURNSTONE_CONSOLE_URL: env var for Docker service discovery
  (0.0.0.0 bind address isn't reachable from other containers)
- HTTP01Handler: ACME client needs a challenge handler even when
  server auto-approves
- Docker overlay: tls-init as root with chmod, Redis conditional
  password, console Redis TLS flags, TURNSTONE_CONSOLE_URL

* feat: full mTLS end-to-end with lacme 1.0.4

Completes the mTLS chain across all services:

lacme 1.0.4:
- Dual EKU certs (serverAuth + clientAuth) — fixes mTLS rejection
- Configurable CA name (name="turnstone") — consistent store key

Bootstrap CA import:
- Console imports bootstrap CA from /certs volume on first boot
- Single trust root: bootstrap CA → console → all service certs

Bridge mTLS:
- TLSClient init when TURNSTONE_TLS_ENABLED set
- Auto-upgrades server URL from http:// to https://
- SSLContext passed to all 3 httpx clients via verify=

Console collector mTLS:
- upgrade_tls() method replaces httpx client with mTLS context
- Called in lifespan after cert issuance alongside proxy upgrade
- Fixes "Failed to poll node" when server serves HTTPS

Docker overlay:
- TURNSTONE_TLS_SANS on all services (Docker service names as SANs)
- TURNSTONE_TLS_ENABLED on bridge
- Channel service with Redis TLS flags
- TURNSTONE_CONSOLE_URL for service discovery
- Server healthcheck disabled (mTLS healthcheck deferred)
- Redis conditional password from env

Verified end-to-end: bootstrap → console CA → server HTTPS →
bridge mTLS → Redis TLS → channel Redis TLS → console collector
polls server over mTLS → workstream creation works through bridge

* fix: lint + copilot feedback on TLS Docker e2e

- SIM105: contextlib.suppress(PermissionError) for chmod
- F401: remove unused get_storage import in bridge
- Redis healthcheck: pass password when REDIS_PASSWORD is set

* fix: sort imports in admin.py and bridge.py

* fix: tls-init key permissions, healthcheck env, collector race

- tls-init: add set -e, chown to turnstone:turnstone with restrictive
  perms (keys 0600, certs 0640, dirs 0750) instead of world-readable
- Redis healthcheck: use container runtime $$REDIS_PASSWORD instead of
  Compose-time interpolation for consistency with --requirepass block
- collector upgrade_tls(): don't close old httpx client while concurrent
  poll threads may still be using it — let GC handle cleanup
2026-03-26 14:24:44 -07:00
Patrick Buckley b086390558 fix: TLS deferred work — wiring, security, tests, Docker overlay (#184)
* fix: TLS deferred work — wiring, security, tests, Docker overlay

Security fixes:
- PostgreSQL SSL: validate sslmode against known values, urlencode
  all params to prevent URL injection
- ConfigStore env seeding: removed redundant type coercion, delegate
  to validate_value() which handles all coercion correctly

Functional wiring:
- Database SSL: init_storage() passes SSL params to PostgreSQL URL
- Server: env var fallbacks for DB SSL (TURNSTONE_DB_SSLMODE etc.)
- Proxy mTLS: re-create proxy clients after TLS cert issuance
- Channel gateway: --ssl-certfile/keyfile/ca-certs CLI args, HTTPS
  advertise URL when SSL configured
- ConfigStore env seeding: TURNSTONE_{SECTION}_{KEY} seeds on first boot
- Console deregistration on shutdown (with debug logging)

Specs, tests, Docker:
- OpenAPI: 5 TLS admin endpoints in console_spec.py
- Auth enforcement test (401 without auth)
- SDK ValueError test (mismatched cert/key)
- Docker overlay: TURNSTONE_TLS_ENABLED, bridge --redis-tls, Redis
  healthcheck with client cert
- Removed stale type:ignore comments (lacme 1.0.2 type stubs)

* review: address copilot feedback on TLS deferred work

- ConfigStore env seeding: use config_store.set() instead of
  storage.set_system_setting() (correct API, updates cache)
- Remove unused defn variable (iterate SETTINGS keys only)
- Fix structlog call-arg error (positional args, not kwargs)
- Channel gateway: validate cert+key provided together
- Restore type:ignore[no-any-return] for CI mypy (lacme 1.0.2
  type stubs not in CI's mypy overrides yet)

* fix: rename _VALID_SSLMODES to lowercase (N806)
2026-03-25 22:43:35 -07:00
Patrick Buckley d08a57dfc2 feat: SDK TLS support, Docker Compose overlay, TLS docs (#183)
Python SDK:
- ca_cert, client_cert, client_key on all 4 client classes
- ValueError if only one of client_cert/client_key provided
- Passed to httpx verify=/cert=

TypeScript SDK:
- TlsOptions type exported (zero runtime code)
- Fix picomatch vulnerability (npm audit fix)

Docker Compose:
- deploy/docker-compose.tls.yml overlay with tls-init bootstrap
- Notes it's an overlay requiring a base compose file

Documentation:
- docs/tls.md: architecture, config, CLI, SDK examples, troubleshooting
- Fixed package name (@turnstone/sdk), Node.js 18+ note
2026-03-25 20:43:34 -07:00
Patrick Buckley 9fdf51ff3d feat: TLS admin UI, CLI cert management, Redis/PG TLS (#182)
Admin API (require admin.settings):
- GET /v1/api/admin/tls/certs, POST .../renew, DELETE .../certs/{domain}
- renew_cert() updates in-memory bundles immediately

Admin UI (instrument panel grid pattern):
- TLS tab with CA status bar, cert grid, renew/delete actions
- Uses admin-row/admin-colheaders grid system (consistent with 12+ tabs)
- showConfirmModal for destructive actions, aria-labels on buttons
- Expired certs show "EXPIRED" text prefix + red color (WCAG 1.4.1)
- Loading state, empty state, error state

CLI (turnstone-admin):
- tls-bootstrap: offline CA + cert issuance (dir perms 0700)
- tls-issue: ACME cert request with key perms 0600
- tls-ca-cert: SHA-256 fingerprint for TOFU verification
- tls-list: auth via --auth-token or config token

Redis/PG TLS:
- RedisBroker + AsyncRedisBroker: ssl params wired through
- broker_from_args + async_broker_from_args: forward TLS kwargs
- add_redis_args: --redis-tls CLI flags
- Config map: [redis] and [database] TLS passthrough
2026-03-25 18:55:11 -07:00
Patrick Buckley 45471894da refactor: adopt lacme 1.0.2 — eliminate loopback client + temp file boilerplate (#181)
lacme 1.0.2 ships four features that simplify turnstone's TLS code:

- RenewalManager CA-direct mode: console renewal now uses ca= param
  instead of a loopback ACME client. No network, no startup ordering
  dependency, no client lifecycle management.
- ACMEResponder serves /ca.pem natively: removed custom route handler
  and route ordering workaround.
- write_pem_files_persistent: replaced manual temp file creation,
  chmod, and atexit cleanup with lacme's secure PEM file helper.
- Removed port param from TLSManager (was only for loopback URL).

Net: ~50 lines removed, two tech debt items resolved.
2026-03-25 18:02:26 -07:00
Patrick Buckley c0b5952573 feat: mTLS service clients with ACME auto-provisioning (#180)
TLSClient class for service nodes — discovers console via services
table, fetches CA cert, requests cert via ACME, provides SSL contexts:

- Console self-registers in services table for discovery
- Services auto-discover console URL from DB (no extra config)
- Initial cert request over plain HTTP (ACME provides integrity)
- Auto-renewal via RenewalManager in server lifespan
- Unauthenticated /acme/ca.pem endpoint for node bootstrapping

RenewalManager fix (was passing client=None):
- Console creates loopback ACME client for self-renewal
- Proper async lifecycle (aenter/aexit) with clean shutdown

Integration points wired:
- Server: TLS init before uvicorn, temp PEM files (0o600, atexit
  cleanup), auto-renewal in lifespan
- Bridge: tls_verify + tls_cert params on all 3 httpx clients
- Console collector: tls_verify + tls_cert params on httpx client
- Console proxy: mTLS context from TLSManager on proxy clients
- Channel gateway: optional SSL params on uvicorn.Config
- Console main(): reads tls.enabled, creates TLSManager, passes
  to create_app with console_url

6 tests for TLSClient (discovery, defaults, backward compat)
2026-03-25 17:48:34 -07:00
Patrick Buckley b9d5b5b671 feat: console CA + ACME server via lacme (#179)
TLSManager class owns the internal CA, ACME responder, and cert
lifecycle:
- CertificateAuthority with DB-backed storage (StorageStore adapter)
- ACMEResponder mounted at /acme (auto_approve for internal network)
- Dual cert issuance: internal CA for mTLS, optional external ACME CA
  for frontend HTTPS (Let's Encrypt via acme_directory setting)
- Auto-renewal via RenewalManager with clean async shutdown
- Expired cert detection on reload (re-issues instead of loading stale)
- EventDispatcher wired to structlog + lacme Prometheus metrics
- GET /v1/api/admin/tls/ca.pem — root cert download
- GET /v1/api/admin/tls/ca — CA status + cert inventory
- Console lifespan: init CA, issue certs, start renewal, stop on shutdown
- 11 async tests covering CA lifecycle, cert persistence, SSL contexts,
  endpoints, and event wiring
2026-03-25 16:35:55 -07:00
Patrick Buckley 274c97135e feat: TLS storage backend + config for lacme integration (#178)
Storage layer for mTLS certificate management via lacme:

- Migration 026: tls_account_keys, tls_ca, tls_certificates tables
- 8 protocol methods on StorageBackend (save/load account keys, CA,
  certs; list/delete certs)
- SQLite and PostgreSQL implementations with dialect-specific upserts
- StorageStore adapter bridging lacme's Store protocol to turnstone
  storage (bytes↔str PEM conversion, CertBundle↔dict mapping)
- Settings registry: tls.enabled (bool), tls.acme_directory (string)
- Config.toml: [redis] TLS and [database] SSL passthrough params
- lacme>=1.0.1 as optional [tls] dependency
- 20 unit tests covering storage CRUD + adapter + crypto roundtrip
2026-03-25 16:07:05 -07:00
Patrick Buckley e87f8e19c2 feat: adopt eval-optimized system prompt for plan_agent pattern
Update plan_agent tool pattern to show codebase exploration before
delegating to the planning agent. This pattern achieved 100% pass
rate (160/160 runs) on the eval suite — up from 98% on the previous
prompt.
2026-03-25 13:32:01 -07:00
Patrick Buckley bb221f4dab chore: bump v0.8.8, update vendored katex 0.16.40 → 0.16.42 2026-03-25 12:00:26 -07:00
Patrick Buckley 361876b17a feat: eval pipeline improvements + tool description optimization (#174)
Eval pipeline:
- --optimize-tools mode freezes system prompt, optimizes tool descriptions only
- Analyst sees available tool list (prevents hallucinated "tool not in schema")
- Analyst sees current tool descriptions in --optimize-tools mode
- Three-layer timeout defense: httpx timeout + _cancelled event + client.close()
- Filter MCP-only tools (read_resource, use_prompt) from headless eval
- Pattern-over-rules framing in optimizer, analyst, and observer prompts
- Tool description diffs logged after each iteration
- Tool optimizer failure retries instead of stopping the loop

Tool renames (avoid chat template channel collision on local models):
- create_plan -> plan_agent
- task -> task_agent

Tool descriptions (from eval-driven optimization, 79% -> 98%):
- bash: environment question examples, disambiguation from write_file/man
- edit_file: multi-file workflow, prerequisite clarification, docstring example
- man: "questions about flags are tool-use tasks" prefix
- math: simple example up front
- plan_agent: "delegate to sub-agent" framing, negative boundary for direct edits
- read_file: multi-file workflow hint
- search: trigger phrases, prerequisite clarification, disambiguation
- write_file: immediate action framing, placeholder example

System prompt: enriched tool patterns from eval results, added identity opener

Test suite: plan-before-refactor -> plan-when-asked (simplified)

Docs: comprehensive eval.md rewrite covering all current features
2026-03-25 11:58:10 -07:00
renovate[bot] 5d26cd6593 chore(deps): update dependency katex to v0.16.42 (#175)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-25 11:57:59 -07:00
renovate[bot] 2d4420e00d chore(deps): lock file maintenance (#177)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-25 11:57:47 -07:00
renovate[bot] b20548583d chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.11.1 (#176)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-25 11:57:44 -07:00
Patrick Buckley f63b2915cc review: address copilot feedback on user_id trust check
Remove console-proxy from trusted_sources — end-user tokens via the
console proxy already carry the real user_id in the JWT, so they must
not be able to override it via the request body (impersonation risk).
Only bridge and console service identities are trusted to forward
user_id on behalf of users. Add 5 tests covering the trust boundary.
2026-03-24 03:13:23 -07:00
Patrick Buckley bf85bbea94 fix: resolve user_id to username in usage and audit displays
Usage tab grouped by user showed raw hex user_id instead of username.
Audit tab showed truncated hex. Now both API endpoints resolve user_id
to username via list_users() lookup before returning the response.
2026-03-24 03:13:23 -07:00
Patrick Buckley 803d8ee8f9 docs: document user_id propagation through MQ path
Update security.md with trusted service user_id forwarding. Update
MQ protocol diagram to include user_id field on CreateWorkstreamMessage.
Update console data flow diagram to show user_id in message and bridge
forwarding.
2026-03-24 03:13:23 -07:00
Patrick Buckley 17b5961a70 fix: propagate user_id through MQ workstream creation path
Console create_workstream was constructing CreateWorkstreamMessage
without setting user_id, so workstreams created via console→MQ→bridge
→server had empty user_id in usage events. Now the console extracts
user_id from auth_result and passes it through the MQ message. The
bridge forwards it in the HTTP payload, and the server accepts it
from trusted service callers (bridge, console-proxy).
2026-03-24 03:13:23 -07:00
Patrick Buckley 037308f3b1 fix: propagate user identity through console proxy
Console proxy previously used a fixed service identity (console-proxy)
with full {read,write,approve} scopes for all proxied requests, losing
the real user's identity at the proxy boundary. Now mints per-request
short-lived JWTs carrying the authenticated user's actual user_id,
scopes, and permissions so upstream servers record correct audit
attribution and enforce scope narrowing as defense in depth.
2026-03-24 03:13:23 -07:00
Patrick Buckley d7a9895855 feat: tool description optimization + OOM and logging fixes (#172)
* feat: tool description optimization + OOM and logging fixes

Tool description optimization (three-phase pipeline):
- Tool optimizer (phase 2) modifies tool descriptions to resolve
  confusion, gated by --optimize-tools and wrong_tool detection
- _apply_tool_overrides deep-copies modified tools, never mutates TOOLS
- _propose_tool_overrides validates JSON, deep-merges parameter overrides
- tool_overrides on EvolutionNode, plumbed through full eval pipeline
- --save-tools writes best overrides back to turnstone/tools/*.json
- Prompt optimizer informed when tool descriptions have been modified
- TSV tool_changes column

OOM fix (session lifecycle):
- HeadlessSession created inside retry loop, not outside — timed-out
  orphan threads no longer pin old sessions in memory
- Session ref cleared immediately after extracting results
- Previous behavior leaked unbounded memory per timeout (~515GB OOM)

Logging fix:
- Removed _suppress_stdout entirely — redirected fd 1 process-wide,
  causing main thread print() to vanish during slow API calls
- NullUI already discards session output; tools return strings

New CLI: --optimize-tools, --tool-optimizer-model/base-url, --save-tools

* review: address copilot feedback on tool optimization
2026-03-23 22:47:06 -07:00
Patrick Buckley 0ba49b8bb7 review: address copilot feedback on eval pipeline
- Record prompt_variant in parallel execution path (was serial-only)
- Handle "Subprocess error:" and "Skipped (fast-fail)" in failure
  classifier instead of mis-bucketing as missing_tool
- Build case_id->case_def dict once in _build_failure_analysis,
  _run_analyst, _propose_prompt_modification (was O(n²) linear scan)
- Enforce original prompt at slot 0 of cached user_prompts variants
- Use wall clock time for TSV elapsed_s instead of sum of run times
2026-03-23 19:44:32 -07:00
Patrick Buckley 51b5b3ee74 feat: multi-agent eval pipeline with tree search, analyst, diversifier
UCB tree search for prompt optimization (arXiv:2603.18620):
- EvolutionNode dataclass, UCB1 selection, rolling mean scores
- Replaces fragile linear chain with backtracking via tree
- Holdout set separation prevents optimizer overfitting
- Improvement-based delta feedback to optimizer

Multi-agent optimization pipeline:
- Analyst agent (phase 1): multi-turn with math/bash tools, identifies
  semantic failure patterns, computes statistics across test results
- Optimizer (phase 2): uses analyst diagnosis to edit developer prompt
- Observer: tunes optimizer strategy every 3 iterations
- Diversifier: generates paraphrased prompt variants for phrasing
  robustness, with dedup, delta generation, and JSON caching

Failure classification:
- 8 failure mode buckets (no_tool_call, wrong_tool, missing_tool,
  wrong_args, extra_tools, timeout, error, json_dump)
- Consistency signals (systematic, flaky, marginal)
- Rule-based pre-analysis feeds into analyst as structured input

Logging and observability:
- Config summary at startup (models, case count, runs)
- Per-case diversifier progress with dedup stats
- UCB selection reasoning, node score updates, tree growth
- Extended TSV: node_score, elapsed_s, prompt_len, iter_tokens,
  cumul_tokens columns plus 4-decimal precision

Infrastructure:
- Thread-safe fd-level stdout suppression (os.dup2)
- Prompt variants plumbed through parallel execution path
- Cached variants auto-detected from tests.json user_prompts field

New CLI flags: --explore-constant, --analyst-model/base-url,
--diversifier-model/base-url, --diversify N, --save-variants
2026-03-23 19:44:32 -07:00
Patrick Buckley c3b0ddeba7 fix: add ddgs to mypy ignore_missing_imports for CI compat 2026-03-23 19:11:50 -07:00
Patrick Buckley a533e1c783 fix: use fd-level stdout redirect in eval to avoid thread race
_suppress_stdout() was setting sys.stdout = StringIO() which is
process-global. When send_headless runs in a ThreadPoolExecutor and
blocks on an API call inside the suppression context, the main
thread's print() calls silently go to the StringIO and vanish.

Switch to os.dup2 fd-level redirect which is thread-safe.
2026-03-23 18:15:45 -07:00
Patrick Buckley d8bc78556f bump: v0.8.7 2026-03-23 17:27:23 -07:00
Patrick Buckley 4f5854e768 fix: remove stale type: ignore on ddgs import 2026-03-23 17:10:27 -07:00
Patrick Buckley bf2dc04cb3 feat: UCB tree search for eval prompt optimization
Replace linear optimization chain with UCB1 evolution tree. Each
iteration selects the most promising node to extend, preventing
irrecoverable collapse from bad edits. Also adds improvement-based
delta feedback to the optimizer and optional holdout set separation.

Inspired by "Learning to Self-Evolve" (arXiv:2603.18620).
2026-03-23 17:09:22 -07:00
Patrick Buckley bb894d073b fix: SQLite migrations use batch_alter_table for compat
Migrations 014, 021-024 used bare op.add_column/alter_column/drop_column
which fails on SQLite (no ALTER of constraints). Switch to
batch_alter_table and enable render_as_batch in env.py. Migration 014
also moved UniqueConstraint inline into create_table.
2026-03-23 17:09:13 -07:00
Patrick Buckley b3934a2d14 bump: v0.8.6
Hotfix: DDG web search returning empty results.

- Switch dependency from duckduckgo-search (deprecated shim, empty
  results) to ddgs>=9.0 (actively maintained successor)
- Include ddg extra in Docker image for free web search fallback
- Update all user-facing install instructions to reference ddgs
2026-03-23 16:17:28 -07:00
Patrick Buckley 3d02cf66b4 review: update stale duckduckgo-search references to ddgs 2026-03-23 16:09:03 -07:00
Patrick Buckley 7631b88792 fix: switch DDG dependency from duckduckgo-search to ddgs
duckduckgo-search 8.x is a deprecated shim that returns empty results
(upstream temporarily disabled HTML/Lite backends, Bing backend broken).
The package was renamed to ddgs in v9.x which works correctly.
2026-03-23 16:09:03 -07:00
Patrick Buckley a07172b0c0 fix: include ddg extra in Docker image for free web search fallback 2026-03-23 15:23:12 -07:00
Patrick Buckley f3d33bf44a bump: v0.8.5
Features:
- Pluggable web search backends — DDG as free default, Tavily, MCP (#166)
- --config flag and $TURNSTONE_CONFIG env var (#160)
- Live session config via ConfigStore point-of-use reads (#154)
- PostgreSQL CI integration tests (#156)
- Skill priority ordering (#144)
- Raise scaling limits for 1000-node clusters (#129)

Security:
- Output guard wired into agent loops — plan + task agents (#168)
- Tool policy enforcement in CLI, bridge, and channel (#168)
- Subprocess environment scrubbing — API keys stripped (#168)
- OIDC issuer SSRF validation (#140)
- MCP registry URL scheme validation (#133)
- Output guard enabled in CLI mode (#134)

Reliability:
- Bridge approval/plan review TOCTOU races fixed (#167)
- SQLite WAL mode, eviction cancel, title retry (#151)
- Health monitor OPEN → HALF_OPEN autonomous probe (#152)
- ConfigStore spec alignment (#153)
- Critical production readiness fixes (#147)
- Server startup stampede prevention (#132)
- Python 3.14 CancelledError guard (#146)

Performance:
- Conversations index, batch config saves, capabilities cache (#149)

Quality:
- Bridge stress tests (6 scenarios, 100 iterations each) (#157)
- Governance SDK, MCP reload, skill config integration tests
- Structlog standardization across 19 modules (#150)
- Dead code removal (#148)
2026-03-23 15:00:08 -07:00
Patrick Buckley fdb1a189e8 fix: show policy deny reason in CLI approval output
Denied tools now print the error text (e.g. "Blocked by tool policy")
in red below the header, so the user sees why a tool was blocked.
2026-03-23 14:55:14 -07:00
Patrick Buckley 58e2d9348f review: fix mypy, tighten env scrub, bridge storage safety
Address Copilot + code review feedback:
- Fix mypy: rename tool_names → _policy_names in CLI to avoid type clash
- Tighten env scrub from substring to suffix matching (_KEY, _TOKEN, etc.)
  to avoid false positives on MONKEYTYPE, KEYBOARD_LAYOUT
- Add DATABASE_URL/TURNSTONE_DB_URL to explicit scrub list
- Bridge: use _storage directly instead of get_storage() which
  auto-initializes a local SQLite DB with no admin policies
- Discord bot: add self.storage None guard
- Add tests for suffix-only matching and false positive avoidance
2026-03-23 14:55:14 -07:00
Patrick Buckley 771d03b8e6 review: fix LESS prefix leak, move policy before auto-approve, add tests
- Move LESS/LESSOPEN/LESSCLOSE/LESSPIPE/LESSCHARSET to _SAFE_NAMES
  instead of prefix matching (prevents LESS_SECRET_TOKEN leak)
- Move bridge policy evaluation before auto-approve check so deny
  policies override auto-approve
- Add storage None guard in Discord bot
- Clean up _policy_handled pattern in Discord bot
- Add debug logging on policy evaluation exceptions
- Add tests: extra overrides scrub, LESS prefix safety
2026-03-23 14:55:14 -07:00
Patrick Buckley d147aaea36 security: scrub secrets from subprocess environments
New turnstone/core/env.py provides scrubbed_env() that strips API keys,
tokens, passwords, and credentials from os.environ before passing to
subprocesses. Applied to all 5 subprocess call sites: _exec_bash,
_exec_search, _exec_man, watch _run_command, and MCP stdio servers.

Pattern-based scrubbing (KEY, SECRET, TOKEN, PASSWORD, CREDENTIAL
substrings) plus explicit blocklist for known secrets. Safe vars
(PATH, HOME, locale, etc.) always preserved. Passthrough list for
operator overrides.
2026-03-23 14:55:14 -07:00
Patrick Buckley 8b747178e0 security: enforce tool policies in CLI, bridge, and channel
evaluate_tool_policies_batch() was only called in the server WebUI.
CLI, bridge, and channel entry points now evaluate admin-defined tool
policies before auto-approve checks. Deny policies block tools, allow
policies auto-approve. Best-effort: gracefully skipped if storage is
unavailable.
2026-03-23 14:55:14 -07:00
Patrick Buckley d57280d807 security: wire output guard into agent loops
_run_agent (plan + task agents) now passes tool results through
_evaluate_output() before appending to context — same as the main
session loop. Runs before truncation so the guard sees full output.
Catches prompt injection, credential leakage, and encoded payloads
in agent tool results that were previously unscanned.
2026-03-23 14:55:14 -07:00
Patrick Buckley b9870f279c fix: bridge approval & plan review TOCTOU races (#158, #159) (#167)
* fix: bridge approval & plan review TOCTOU races (#158, #159)

Replace "pop on completion" with a tombstone pattern — pending entries
are marked resolved=True instead of being removed, eliminating the
window where stale SSE reconnect events bypass the duplicate guard.
Resolved tombstones are cleaned up on ws_state events and ws_closed.

Stress tests now pass reliably (previously ~12-16% failure rate).

* review: extract _mark_resolved helper, use real ws_closed path in test, add refinement loop test

Address code review suggestions:
- Extract _mark_resolved() helper in _wait_plan to reduce duplication
- Document cross-stream ordering assumption for plan review refinement
- Race 6 test now calls _handle_global_event instead of manual dict pops
- New Race 7 test validates plan review refinement loop (tombstone → cleanup → re-entry)

* fix: add TTL fallback for tombstone cleanup when global SSE lags

If the global SSE stream is temporarily down while per-WS SSE continues,
resolved tombstones would block legitimate new approvals/plan reviews.
Add a 30s TTL so stale tombstones are expired in the duplicate guard
as a fallback to the normal ws_state-based cleanup.

Also changes tombstone type from (request_id, bool) to
(request_id, float) where 0.0 = active, >0 = resolved_at monotonic time.

* fix: use 3x approval_timeout for tombstone TTL instead of hardcoded 30s

Tie the TTL to the configurable approval_timeout (default 300s = 900s TTL)
rather than a short hardcoded value. The TTL is only a fallback for when
the global SSE stream is completely down — a conservative value is safer.
2026-03-23 14:04:12 -07:00
Patrick Buckley 71ee340bc6 feat: pluggable web search backends (DDG, Tavily, MCP) (#166)
* feat: pluggable web search backends (DDG, Tavily, MCP)

web_search is now an abstract capability with swappable backends:

- DuckDuckGoClient — free, no API key, uses duckduckgo-search library
- TavilyClient — existing behavior, requires API key
- MCPSearchClient — delegates to any MCP server tool

New tools.web_search_backend setting (ConfigStore + --web-search-backend
CLI flag): '' (auto), 'tavily', 'ddg', or 'mcp:server:tool'.

Auto-detection (default): Tavily if key present, else DDG if installed,
else disabled. This means users with duckduckgo-search installed get
web search for free with local models — no API key needed.

Closes #131

* fix: address Copilot review on pluggable web search

- Unknown backend values now log warning + return None (not silent
  fallthrough to auto-detect)
- Pass timeout to DDGS constructor
- MCP: use math.ceil for timeout, forward topic kwarg
- Fix agent mode web_search gating to use _resolve_search_client()
  instead of get_tavily_key() (was still using old check)
- Update docstring to reflect new backend resolution
- Fix DDG test to patch DDGS import properly
- Add test for unknown backend rejection
2026-03-23 13:21:04 -07:00
Patrick Buckley c5d5d0b7cd fix: update-vendored-js.sh detects old version from filesystem
The script detected the old version from pyproject.toml, which Renovate
had already updated. This caused OLD_DIR == NEW_DIR, so the script
downloaded files then immediately deleted them.

Fix: detect old version from the actual directory on disk. Add a guard
that errors if old == new version to prevent silent data loss.

Also: run the fixed script to vendor katex 0.16.40 (fonts + css + js).
2026-03-23 13:03:49 -07:00
renovate[bot] 1f9d03c3e0 chore(deps): update dependency katex to v0.16.40 2026-03-23 13:03:49 -07:00
renovate[bot] 24e082df05 chore(deps): update postgres docker tag to v18 2026-03-23 12:55:27 -07:00
renovate[bot] 4a78d20eea chore(deps): update dependency typescript to v6 2026-03-23 12:55:18 -07:00
renovate[bot] e0d17e0f99 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.10.12 2026-03-23 12:55:08 -07:00
renovate[bot] cd6c49dd01 chore(deps): update dependency vitest to v4.1.1 (#162)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-23 19:53:13 +00:00
Patrick Buckley 8454e961ba feat: --config flag and $TURNSTONE_CONFIG env var (#160)
* feat: --config flag and $TURNSTONE_CONFIG env var for config.toml path

Add set_config_path() to config.py with three-tier resolution:
  1. --config CLI flag (via set_config_path)
  2. $TURNSTONE_CONFIG environment variable
  3. ~/.config/turnstone/config.toml (default)

--config added to all 5 entry points that load config.toml: CLI,
server, console, bridge, eval. Uses parse_known_args pre-parse so
the path is resolved before apply_config reads the file.

Closes #130

* fix: centralize --config pre-parse, fix help and docstrings

- Add add_config_arg() helper with separate pre-parser (add_help=False)
  so --help still shows config-derived defaults
- Replace duplicated pre-parse blocks in all 5 entry points
- Fix set_config_path docstring (works after load_config too)
- Fix module docstring precedence description
- Remove redundant import os in get_tavily_key
2026-03-23 12:26:41 -07:00
Patrick Buckley 4ae38bc2ae test: bridge race condition stress tests (#157)
* test: bridge race condition stress tests (5 scenarios)

Repetition-based stress harness (100 iterations per scenario) targeting
threading races in bridge.py:

1. Duplicate approval on SSE reconnect — xfail, confirms known TOCTOU
   race where _wait_approval pops pending entry allowing duplicate
2. Duplicate plan review on SSE reconnect — xfail, same pattern
3. approve_set consistency during concurrent update — passes
4. _running flag visibility across threads — passes
5. Workstream closure during blocked pop_response — passes
6. Concurrent approval + workstream close — passes (no orphaned state)

Two real races confirmed (marked xfail with fix descriptions).

* fix: address Copilot feedback on bridge stress tests

- Fix plan review mock to use correct message type ("plan_feedback")
- Replace fixed sleeps with bounded _wait_pending_clear() polling
- Add assert not t.is_alive() after all thread joins
- Update Race 5 description to reflect timeout validation (not
  closure-unblocks-pop)
- Update plan review xfail reason to mention generation counters
2026-03-23 11:36:42 -07:00
Patrick Buckley ab1a71c86c feat: add PostgreSQL CI integration tests (#156)
* feat: add PostgreSQL CI integration tests

Add --storage-backend pytest option and shared storage_backend fixture
in conftest.py that creates SQLiteBackend or PostgreSQLBackend based
on the flag. Migrate 13 storage test files to use shared fixture
instead of local SQLiteBackend fixtures.

Add test-postgres CI job with PostgreSQL 17 service container that
runs the full test suite against real PostgreSQL.

* fix: use TRUNCATE CASCADE for PG cleanup, wrap in try/finally

TRUNCATE is faster than per-table DELETE and resets autoincrement
sequences. try/except ensures reset_storage() always runs even if
cleanup fails due to a corrupted connection from a failing test.

* fix: document _engine coupling in PG cleanup comment
2026-03-23 11:11:40 -07:00
renovate[bot] ce57df6888 chore(deps): lock file maintenance 2026-03-23 11:04:43 -07:00
Patrick Buckley 275f40eebb feat: live session config via ConfigStore point-of-use reads (#154)
* feat: live session config via ConfigStore point-of-use reads

Existing sessions now pick up admin settings changes without requiring
workstream recreation. ChatSession reads MemoryConfig and JudgeConfig
behavioral flags from ConfigStore at point-of-use via _mem_cfg and
_judge_cfg properties. Judge LLM client config (model, provider,
base_url, api_key) stays frozen from creation time.

Falls back to frozen dataclasses when ConfigStore is absent (CLI mode).

* fix: type config_store param, clarify _ensure_judge guard comment

* fix: re-check live judge.enabled on every _ensure_judge call

Copilot correctly identified that the cached judge was returned without
re-checking the live enabled flag. Move the enabled check before the
cache check so disabling the judge via admin takes immediate effect.
Also defer JudgeConfig import to local scope in _judge_cfg property
to avoid pulling in the full judge module at import time.
Add test for disable-after-init scenario.
2026-03-21 19:16:06 -07:00
Patrick Buckley 2c510f8617 fix: medium reliability — SQLite WAL, eviction cancel, title retry (#151)
* fix: medium reliability — SQLite WAL + timeout, eviction cancel, title retry

M1: Increase SQLite busy timeout to 30s and enable WAL journal mode
    for better concurrent read/write. Prevents OperationalError under
    multi-workstream write contention.

M2: Call session.cancel() during workstream eviction cleanup so
    in-flight worker threads stop promptly instead of running to
    completion on an evicted workstream.

M3: Reset _title_generated flag on exception so title generation
    retries on the next successful exchange instead of permanently
    giving up after one failure.

* fix: address review — WAL pragma error handling, title retry ws_id guard

Wrap WAL pragma in try/except and verify returned mode. Log warning if
WAL is not enabled (e.g., filesystem permissions) instead of aborting
connection.

Guard title retry flag reset with ws_id comparison to prevent
re-enabling titling for a different workstream after /resume.

* fix: address review — add title retry tests

Two tests verifying _title_generated flag behavior: reset on failure
(allows retry on next turn), stays True on success. Covers the new
retry logic added in this PR.

* fix: guard title update success path against ws_id change during resume

Use captured ws_id on success path (not just failure path) so a
concurrent resume() can't cause the background title thread to rename
the wrong workstream. Add test for the race scenario.
2026-03-21 17:02:25 -07:00
Patrick Buckley 2afb9c7f72 fix: health monitor probe loop transitions OPEN → HALF_OPEN autonomously (#152)
The probe loop continued probing while the circuit was OPEN but never
transitioned to HALF_OPEN — that only happened inside
acquire_request_permit() which requires a user request. If no user
sends a message during the cooldown, the circuit never recovers.

Now the probe loop checks cooldown elapsed and transitions to HALF_OPEN
before probing, so recovery happens automatically without user
interaction.
2026-03-21 16:35:47 -07:00
Patrick Buckley 5b8ab94446 fix: align ConfigStore implementation with spec (#153)
* fix: align ConfigStore implementation with spec

- Add cluster + skills sections to admin UI settings order and labels
- Return default value in DELETE /v1/api/admin/settings response per spec
- Document 4 missing settings in docs/settings.md (trusted_proxies,
  output_guard, redact_secrets, discovery_url) and correct count to 48
- Wire ConfigStore into console server replacing 4 raw
  get_system_setting() calls with validated/cached config_store.get()
- Reload console ConfigStore on settings mutations via
  _publish_config_change()
- Update registry URL tests for ConfigStore-based resolution

* fix: address Copilot review feedback on ConfigStore PR

- Move config_store.reload() before collector guard in
  _publish_config_change() so cache refreshes even without collector
- Add DeleteSettingResponse schema and update OpenAPI spec to match
  the actual delete response (status + key + default)
- Add test asserting default field in delete response
- Fix stale docstring in test helper
2026-03-21 16:12:50 -07:00
Patrick Buckley 30828e9f9c perf: conversations index, batch config saves, capabilities cache (#149)
* perf: add conversations.timestamp index, batch config saves, cache capabilities

P1: Add idx_conversations_timestamp index (migration 025) to eliminate
    full table scans on search_history_recent ORDER BY timestamp DESC.

P2: Batch save_workstream_config — replace N separate SQL statements
    with single executemany call. SQLite uses INSERT OR REPLACE,
    PostgreSQL uses INSERT ON CONFLICT DO UPDATE.

P3: Cache _get_capabilities() result on ChatSession — called 4-6x per
    turn but deterministic for session lifetime. Invalidated on model
    switch.

* fix: address review — capabilities cache bypassed for fallback models

Cache only applies to the primary session model. Fallback models
(different provider/model passed to _get_capabilities) resolve fresh
to avoid stale capability flags affecting tool selection and web search.
2026-03-21 04:29:43 -07:00
Patrick Buckley 6d0dc6df94 chore: standardize logging to structlog get_logger across 19 modules (#150)
Replace bare `import logging` / `logging.getLogger(__name__)` with
`from turnstone.core.log import get_logger` / `get_logger(__name__)`
across all core modules and server.py. This enables structured log
context injection (node_id, ws_id, request_id) in modules that
previously used plain stdlib logging.

Also adds _ensure_stdlib_factory() to log.py for pytest caplog
compatibility when configure_logging() hasn't been called.

Renames `logger` to `log` in skill_sources.py for naming consistency.
2026-03-21 04:29:40 -07:00
Patrick Buckley 7e680ee883 chore: remove dead code — chat.py, singular touch, unused vars, inline imports (#148)
* chore: remove dead code — chat.py shim, singular touch method, unused vars

- Delete turnstone/chat.py (backward-compat re-export shim, zero importers)
- Remove touch_structured_memory() singular method from protocol + both
  backends + 6 tests (only plural batch form is used)
- Remove unused _last_err variable in _compact_messages
- Remove redundant _AGENT_AUTO_TOOLS / _TASK_AUTO_TOOLS class aliases,
  use module-level constants directly
- Consolidate ~76 inline schema imports to top-level in both storage
  backends (channel_users, channel_routes, oidc_*, scheduled_tasks,
  watches, services)

Net: -219 lines

* fix: address review — remove stale inline timedelta imports in prune_task_runs

timedelta is already imported at module scope in both backends.
2026-03-21 04:29:37 -07:00
Patrick Buckley e950219246 docs: add beta status warning to README
Mark platform as experimental beta with explicit disclaimers: no
guarantees of determinism, reliability, or backward compatibility.
Advise thorough evaluation before deployment.
2026-03-21 03:43:13 -07:00
Patrick Buckley b3764a8035 fix: critical reliability fixes for production readiness (#147)
* fix: critical reliability fixes for production readiness

C1: Add 1-hour timeout to _approval_event.wait() and _plan_event.wait()
    to prevent permanent worker thread hangs when users disconnect.

C2: Atomically check-and-start worker thread under Workstream._lock to
    prevent race condition where two concurrent send_message requests
    spawn duplicate workers on the same non-thread-safe ChatSession.

C3: Bound _watch_pending queue to maxsize=20 to prevent OOM under
    heavy watch load with busy workstreams.

H1: Add timeout to proc.wait() (10s) and stderr_thread.join() (5s)
    after SIGKILL to prevent indefinite hang on D-state processes.

H2: Protect _pending_verdicts with _ws_lock at all three mutation sites
    (reset in approve_tools, append in on_intent_verdict, swap-and-clear
    in resolve_approval) to prevent lost verdicts from concurrent
    judge daemon and approval threads.

H3: Bound global SSE queue to maxsize=10000 with put_nowait() and
    contextlib.suppress(queue.Full) for backpressure. Prevents
    unbounded memory growth when fanout thread is overloaded.

H4: Bridge SSE threads for closed workstreams now check ws_id membership
    in _ws_threads before reconnecting, preventing thread leak on
    workstream close.

* fix: address review — verdict lock consistency, watch queue non-blocking, SSE drop logging

- Move _last_verdict_decision set inside _ws_lock in resolve_approval()
  so swap+decision is atomic with on_intent_verdict() reads
- Read _last_verdict_decision under _ws_lock in on_intent_verdict()
- Build heuristic_verdicts locally then assign under lock in approve_tools()
- Use resolve_approval() for timeout path so verdicts are updated consistently
- Watch queue producer uses put_nowait with log on Full (prevents WatchRunner hang)
- Global SSE state broadcasts log on queue.Full instead of silent suppress
- Plan event wait also gets 1-hour timeout (same class of bug as approval)
2026-03-21 03:28:01 -07:00
Patrick Buckley 756c4d8929 fix: guard against CancelledError on MCP startup future (Python 3.14) (#146)
On Python 3.14, Future.exception() raises CancelledError on cancelled
futures instead of returning None. Check future.cancelled() before
calling exception() to prevent crash when the MCP event loop shuts down
before _connect_all completes.
2026-03-21 01:06:11 -07:00
Patrick Buckley 04c50568e9 feat: add priority column for skill ordering control (#144)
* feat: add priority column for skill ordering control

Add priority INTEGER DEFAULT 0 column to prompt_templates (migration
024). Skills with activation="default" are now ordered by priority ASC,
name ASC instead of name-only. Admins can set priority via create/update
API. Lower values run first. Priority is editable on readonly/installed
skills. 4 new tests. Python SDK, TypeScript SDK, and Pydantic models
updated.

* fix: address review — apply priority ordering to list_default_templates

list_default_templates() still ordered by name only, so priority had
no effect on default skill execution order. Update both SQLite and
PostgreSQL backends to order by (priority, name).

* fix: address review — regenerate OpenAPI snapshot, add default template ordering test

Regenerate openapi-console.json to include priority field on skill
models. Add test_list_default_templates_ordered_by_priority to verify
the execution path for default skills respects priority ordering.
2026-03-21 00:53:51 -07:00
Patrick Buckley 3bf220c503 fix: use approval_label for per-tool always-approve in CLI and bridge (#143)
* fix: use approval_label for per-tool always-approve in CLI and bridge

The server stores approval_label (e.g. mcp__server__tool) for per-tool
auto-approve, but CLI and bridge extracted only func_name (bare tool
name). This caused always-approve decisions to not carry over across
access paths. Align CLI and bridge to prefer approval_label with
func_name fallback, matching the server's WebUI.approve_tools() pattern.

* fix: address review — exclude errored items from bridge auto-approve check

Filter out items with error set from the auto-approve subset check,
matching the server's WebUI.approve_tools() behavior. Prevents
policy-denied items from affecting auto-approve decisions.
2026-03-21 00:42:33 -07:00
Patrick Buckley 4b853e329e test: verify skill_id/skill_version populated in workstreams table (#145)
The workstreams.skill_id and skill_version columns were already being
populated correctly (wired in the skills unification PR #106). Add two
tests confirming: lineage columns set when skill is applied, and
defaults when no skill is used. Check off the PROGRESS.md item.
2026-03-21 00:40:30 -07:00
Patrick Buckley 29ffdc36d0 test: add governance SDK integration tests against real Starlette app (#142)
* test: add governance SDK integration tests against real Starlette app

24 TestClient-based tests verifying round-trip serialization of SDK
governance methods (roles, policies, orgs) against actual route
handlers with SQLite storage. Covers create/list/update/delete
lifecycles, error cases, and Pydantic model field validation.

* fix: address review — close AsyncClient in sdk_client fixture teardown

Convert sdk_client fixture to async context manager so the httpx
AsyncClient is properly closed after tests, avoiding resource leak
warnings.
2026-03-21 00:31:53 -07:00
Patrick Buckley ada8b80509 test: add MCP reload and reconcile endpoint integration tests (#141)
* test: add MCP reload and reconcile endpoint integration tests

11 new tests covering POST /v1/api/admin/mcp-servers/reload (console)
and POST /v1/api/_internal/mcp-reload (node). Verifies reconcile_sync
invocation, fan-out results, permission checks, missing storage
handling, and mixed node error propagation.

* fix: address review — lazy-import internal_mcp_reload to avoid heavy module load

Move turnstone.server import inside _routes_with_internal() helper so
the full server module (which reads UI static assets) is only loaded
when node-side endpoint tests actually run, not during test collection.
2026-03-21 00:14:50 -07:00
Patrick Buckley 1f47ca62de fix: validate OIDC issuer URLs against SSRF before discovery fetch (#140)
* fix: validate OIDC issuer URLs against SSRF before discovery fetch

Add validate_issuer_url() that rejects private/loopback/link-local IPs,
non-HTTPS (except localhost for dev), embedded credentials, and
unresolvable hostnames. Called before the HTTP fetch in discover_oidc()
so the request is never made for invalid URLs. 17 new tests.

* fix: address review — use is_global, redact userinfo, catch ValueError

Use `not addr.is_global` instead of individual range checks to cover
all non-routable addresses (CGNAT, unspecified, multicast). Redact
credentials from error messages to prevent log leakage. Catch ValueError
from ip_address() for zone-indexed IPv6 addresses.
2026-03-21 00:14:46 -07:00
Patrick Buckley 83d9233304 test: skill session config application to workstreams (#139)
* test: skill session config application to workstreams

13 TestClient-based integration tests verifying that skill session
config fields (model, temperature, token_budget, auto_approve,
allowed_tools, reasoning_effort, agent_max_turns) are correctly applied
to ChatSession and WebUI when creating a workstream with a skill.

Covers: individual fields, combined application, disabled/unknown skill
rejection, zero-value no-ops.

* fix: address review — pass skill kwarg, clarify no-op test assertions

Pass skill=kwargs.get("skill") into ChatSession in test factory to
match production behavior. Clarify zero-value no-op test docstrings
to document they verify the handler's guard conditions, not observable
state changes.
2026-03-20 23:09:59 -07:00
Patrick Buckley bf06102d37 fix: memory access tracking and BM25 context caching (#138)
* fix: memory access tracking and BM25 context caching

Add touch_structured_memory/touch_structured_memories to storage
protocol + SQLite/PostgreSQL backends. Bumps last_accessed and
access_count on memory retrieval (BM25 injection + search results).
9 new storage tests.

Cache the scored BM25 memory context string on ChatSession, invalidated
on memory save/delete. Eliminates ~12 redundant storage queries + index
rebuilds per session lifecycle.

* fix: address review — deduplicate keys in touch facade, clarify contract

Deduplicate keys in the memory.py facade before calling storage so each
distinct memory is touched at most once. Update protocol docstring to
clarify per-call increment semantics. Add deduplication unit test.

* fix: replace unused-import test with real batch duplicate test

Replace facade dedup test (which only tested Python set logic) with a
real storage-level test that verifies duplicate keys each increment
access_count. Fixes ruff F401 lint failure.
2026-03-20 23:08:32 -07:00
Patrick Buckley e015b4512d fix: return typed Pydantic models from SDK skill methods (#137)
Skill methods on TurnstoneConsole and AsyncTurnstoneConsole returned
dict[str, Any] instead of validated Pydantic models. Update list_skills,
create_skill, get_skill, update_skill, list_skill_resources,
create_skill_resource, and install_skill to use response_model= with
ListSkillsResponse, SkillInfo, SkillResourceInfo, and
SkillInstallResponse.
2026-03-20 20:02:06 -07:00
Patrick Buckley 0c1afff7fc test: add _get_registry_url three-tier fallback tests (#136)
8 tests covering the DB setting → config.toml → default URL resolution
chain, including storage errors, empty values, malformed JSON, and
documenting that RuntimeError propagates uncaught through the except
clause.
2026-03-20 20:01:58 -07:00
Patrick Buckley a94051a995 fix: add split pane button to tab bar for discoverability (#135)
* fix: add split pane button to tab bar for discoverability

The split pane feature was only accessible via right-click context menu
or Ctrl+\ keyboard shortcut. Add a subtle split icon (⧉) to the tab
bar that appears at low opacity in single-pane mode. Hidden in
multi-pane mode where pane headers already provide split/close controls.

* fix: address review — change tab-bar from tablist to toolbar role

The tab bar contains both tabs and action buttons (new workstream,
split pane), which is invalid for role=tablist. Change to role=toolbar
which correctly describes a container of mixed interactive controls.

* fix: address design review — WCAG contrast, ARIA structure, mobile

- Drop opacity approach, use border: dashed var(--border) matching
  #new-tab-btn pattern (fixes WCAG contrast failure at 35% opacity)
- Nest tabs in #tab-list[role=tablist] inside toolbar (fixes invalid
  role=tab children inside role=toolbar)
- Hide split button on mobile (<600px) where splits can't work
- Add aria-keyshortcuts to both action buttons
2026-03-20 20:01:35 -07:00
Patrick Buckley 2f906ea1f9 fix: enable output guard in CLI mode (#134)
* fix: enable output guard in CLI mode

The heuristic output guard (credential redaction, prompt injection
detection) only ran in server mode because cli.py never constructed a
JudgeConfig. Additionally, the guard condition in session.py required
enabled=True, coupling the zero-cost heuristic (<5ms) to the full LLM
judge.

Wire JudgeConfig from existing CLI args into the session factory.
Decouple the output_guard condition from the enabled flag so the
heuristic guard runs even when the LLM judge is disabled via --no-judge.

* fix: address review — pass config.toml judge fields to CLI JudgeConfig

apply_config() merges [judge] section from config.toml into args as
judge_base_url and judge_api_key. Pass these through to JudgeConfig so
the CLI respects config.toml judge settings (e.g. separate judge
endpoint).
2026-03-20 20:01:31 -07:00
Patrick Buckley b61bfd1aa6 fix: validate URL scheme after MCP registry template substitution (#133)
* fix: validate URL scheme after MCP registry template substitution

resolve_install_config() substitutes user-provided values into URL
templates via string replacement without validating the resulting URL.
Add urlparse check after substitution to reject non-HTTP(S) schemes,
preventing SSRF-style redirection through crafted template variables.

* fix: address review — reject empty hostname and embedded credentials

Add hostname presence check and userinfo rejection after URL scheme
validation. Prevents URLs like https:///path (no host) and
https://user:pass@host (credential leakage in config). Two new tests.
2026-03-20 20:01:26 -07:00
Patrick Buckley 19c3a48b10 fix: server startup stampede — timeout model detection, non-fatal PG … (#132)
* fix: server startup stampede — timeout model detection, non-fatal PG migrations

detect_model() blocked the main thread for up to 400s when the LLM backend
was unreachable (OpenAI SDK default: 600s read timeout × 2 retries × TCP
retransmit). Cap startup detection at 10s with no retries — the
BackendHealthMonitor handles ongoing availability probing after startup.

PostgreSQL migrations via _run_with_pg_lock() crashed the server on lock
contention when 10 containers stampeded the advisory lock simultaneously.
Wrap in try/except matching the SQLite path — the entrypoint script already
runs migrations before the server process starts.

Health check start_period increased from 15s to 60s to accommodate the
startup sequence under load.

* fix: address review — narrow PG migration except, add detect_model test

Narrow the PG migration except clause to (OSError, EOFError) so DDL
errors still propagate. Add two unit tests for detect_model() verifying
with_options(timeout=10, max_retries=0) is called and that connection
errors in non-fatal mode return (None, None).
2026-03-20 20:01:21 -07:00
Patrick Buckley 414eb52d67 feat: raise scaling limits for 1000-node clusters (#129)
* feat: raise scaling limits for 1000-node clusters

Raise hardcoded limits throughout the codebase so clusters up to 1000
nodes work without configuration changes.

Scaling limits:
- max_workstreams default 10 → 50 (configurable via settings)
- Console fan-out concurrency 50 → 200 (configurable: cluster.node_fan_out_limit)
- MCP max servers 50 → 200 (configurable: cluster.mcp_max_servers)
- Console SSE queue 500 → 2000, server global SSE queue 500 → 1000
- httpx proxy pool: explicit max_connections on both proxy clients
- PostgreSQL pool 5+10 → 2+3 per process (right-sized for short-burst queries)
- Redis pool: explicit max_connections=200 on both sync and async brokers

Performance optimizations:
- Redis list_nodes(): replace N+1 SCAN+GET with SCAN+MGET
- Collector poll: raise thread pool to 200 (matches fan-out limit)
- Server SSE: dedicated ThreadPoolExecutor(200) for queue polling
- Fan-out: new get_all_nodes() removes hardcoded limit=1000 ceiling

Bug fixes:
- Settings reload notification was silently failing (called .get() on tuple)
- Watch fan-out only queried 500 nodes instead of full cluster

New cluster settings (configurable via admin Settings tab):
- cluster.node_fan_out_limit (default 200, range 10-1000)
- cluster.mcp_max_servers (default 200, range 1-2000)

Adds docs/pgbouncer.md for PostgreSQL connection pooling at scale.
Adds ddgStressCluster compose profile (100 nodes, 10 groups of 10).
Updates architecture, console, docker, settings, and API reference docs.

* fix: add image tag to compose anchors to avoid redundant builds

All cluster/stress services inherit `build:` from the anchor, causing
Docker to attempt 200+ separate builds. Adding `image: turnstone:local`
means Docker builds once and all services reuse the cached image.

* fix: address Copilot review feedback on scaling PR

- Remove magic number in get_all_nodes (limit=None instead of 2**31)
- Size httpx proxy pool from fan-out limit setting (not hardcoded 250)
- Cap cluster.node_fan_out_limit max_value to 500, mark restart_required
- Convert _publish_config_change from sync to async (was blocking event loop)
- Use shutdown(wait=True, cancel_futures=True) for SSE executor

* fix: add PostgreSQL env vars to cluster bridge anchor

Bridges initialize storage for auth/migrations but the bridge anchor
was missing TURNSTONE_DB_BACKEND and TURNSTONE_DB_URL, causing all
bridges to fall back to SQLite. With 100 bridges sharing the same
volume, concurrent SQLite migrations corrupt the database.

* fix: address Copilot round 2 + PG connection exhaustion at startup

Copilot feedback:
- Raise cluster.node_fan_out_limit max_value to 1000 (matches target)
- Cache fan-out limit on app.state at startup instead of re-reading DB
  per request (pool and semaphore now use the same value consistently)
- Remove unused params from _publish_config_change

Stress cluster fix:
- Raise PG max_connections to 300 (configurable via POSTGRES_MAX_CONNECTIONS)
  to handle 200 processes connecting simultaneously at startup
- Bump PG shared_buffers to 128MB and memory limit to 1G to match
- Add DB env vars to production bridge service

* fix readme

* fix: startup resilience for large clusters

Server no longer crashes when LLM backend is unreachable at startup.
detect_model() accepts fatal=False, returning (None, None) so the
server starts in degraded mode with circuit breaker open. The health
monitor will detect when the backend becomes available.

Migration runner retries with jittered exponential backoff (up to 10
attempts) when PostgreSQL rejects connections during startup stampedes.

Collector httpx pool sized to match poll workers (was using default of
100 connections with 200 workers).

Also addresses Copilot round 2:
- Raise cluster.node_fan_out_limit max_value to 1000
- Cache fan-out limit on app.state at startup
- Remove unused params from _publish_config_change
- Add DB env vars to production bridge service

* fix: replace silent error suppression with structured logging

Audit and fix 30+ instances of silently swallowed exceptions across 8
files. No-raise contracts are preserved — all changes add logging
while keeping the same return-value behavior.

memory.py (26 changes):
  Every storage operation now logs on failure. Previously the entire
  persistence facade had zero logging — messages, workstream state,
  and structured memories could silently stop being saved.

server.py:
  Usage recording failures now log at warning (was pass).
  Global SSE fan-out errors log at debug (was pass).

console/server.py:
  Config reload notification logs per-node failures at warning.
  Settings read fallbacks log at warning with the default value used.

auth.py:
  User existence check logs at warning (was pass).
  Setup rollback failures log at error (was suppress).
  OIDC state cleanup logs at debug (was suppress).

mcp_client.py:
  DB-managed MCP server list failure logs at warning (was pass).

collector.py:
  Node poll failure upgraded from debug to warning with exc_info.
  Health fetch failure logs at debug with exc_info (was silent).

bridge.py:
  Best-effort plan rejection logs at warning (was suppress).
  Malformed SSE data logs at debug (was suppress).

session.py:
  Tool output UI callback failure logs at debug (was suppress).

* fix: stagger collector poll with deterministic per-node jitter

Each node gets a stable offset within the first half of the poll
interval, derived from hashing the node_id against a Mersenne prime
(2^31 - 1). This spreads HTTP requests across the cycle instead of
firing all 100+ at the same instant.

Also raises poll interval from 10s to 15s and HTTP timeout from 5s
to 30s for large-cluster resilience.

* fix: add startup jitter to bridge heartbeat and health monitor probe

Bridge heartbeat: deterministic per-node jitter (from node_id hash)
spreads initial registration across the first quarter of the heartbeat
TTL. At 100 bridges with 60s TTL, heartbeats spread across 15s instead
of all firing at T=0.

Health monitor probe: deterministic per-process jitter (from PID hash)
spreads initial LLM backend probes across half the probe interval. At
100 servers with 30s interval, probes spread across 15s instead of all
hitting the LLM at T=30.

Both use the same Mersenne prime hashing approach as the collector poll
jitter for consistency.

* fix: split collector httpx timeout and raise keepalive pool

Use separate connect/read/write/pool timeouts instead of a single 30s
for all phases. Raise keepalive connections from 50 to 200 so the
collector reuses TCP connections across poll cycles instead of
constantly tearing down and re-establishing them.

* fix: narrow detect_model return type for CLI and eval callers

detect_model() now returns tuple[str | None, int | None] to support
fatal=False. CLI and eval always use fatal=True (the default), which
guarantees a non-None model or SystemExit. Add assert to narrow the
type for mypy.
2026-03-19 04:53:11 -07:00
Patrick Buckley 86b404177b chore: bump version to 0.8.4
- feat: split-pane layout for chat UI (#127)
- fix: enforce CSS min dimensions during split handle drag
- feat: add OpenShell sandbox policy for turnstone-server (#128)
- fix: collector JWT expiry causes silent workstream data wipe (#126)
- fix: auto-titler SSE event + SSE reconnection after restart (#125)
- fix: wire resume_ws through console + expose max_ws in heartbeat (#124)
2026-03-18 18:13:18 -07:00
Patrick Buckley 10165bb8a1 feat: add OpenShell sandbox policy for turnstone-server (#128)
* feat: add OpenShell sandbox policy for turnstone-server

Curated policy for running turnstone-server inside an OpenShell sandbox
with kernel-enforced security boundaries (Landlock, netns, seccomp).

- Filesystem: workdir read-write, /usr+/etc read-only, /tmp+/dev/null
  read-write, Landlock best_effort compatibility
- Network: default-deny with allowlisted LLM APIs (OpenAI, Anthropic),
  Tavily, skills.sh, GitHub (read-only L7), MCP registry (read-only L7),
  Redis localhost, package registries, curated web_fetch domains
- Git: L7-enforced read-only (info/refs + git-upload-pack only)
- Process: privilege drop to sandbox:sandbox
- Inference routing template for credential isolation (real API keys
  never enter the sandbox, resolved at proxy layer)

* fix: address PR #128 review feedback + add integration guide

Review fixes:
- Use python3 (not python) in usage examples to match binary allowlist
- Fix network_policy → network_policies in comment
- Remove /usr/bin/git from github_api (git uses github.com not
  api.github.com; already covered by git_operations policy)
- Remove pip/uv from bash_network_tools (package_registries already
  covers their PyPI access; no need for StackOverflow/Wikipedia reach)
- Restructure routes.yaml so commented blocks are indented under
  routes: key (uncomment without restructuring YAML)

New: docs/openshell.md covering policy customization, inference routing,
domain allowlisting, MCP subprocess inheritance, and the dual-layer
security model.
2026-03-18 18:09:47 -07:00
Patrick Buckley 5d478573cc fix: enforce CSS min dimensions during split handle drag
Drag ratio bounds were hardcoded at 0.1/0.9 which allowed panes to be
resized below their CSS min-width (200px) / min-height (150px), causing
input areas and text to overflow and clip. Now compute bounds dynamically
from the container size and CSS minimums.
2026-03-18 18:08:35 -07:00
Patrick Buckley 1b24e4717f feat: split-pane layout for chat UI (#127)
* feat: split-pane layout for chat UI

Refactor the server UI from a single-pane global-state design to a
multi-pane architecture with per-workstream Pane instances and a binary
layout tree. Each pane has its own SSE connection, message area, input,
and state (busy, approval, streaming).

Phase 1 — Pane class with 25 prototype methods encapsulating all
per-workstream state. Phase 2 — binary split tree (leaf/split nodes)
with recursive flexbox rendering and drag-to-resize handles. Phase 3 —
keyboard shortcuts (Ctrl+\, Ctrl+Shift+\, Ctrl+Shift+W, Ctrl+Alt+Arrow)
and right-click context menu. Phase 4 — layout persistence via
localStorage.

Key design decisions:
- No duplicate workstreams across panes (split refused if no unused ws,
  auto-close redundant pane on ws deletion)
- Max 6 panes to avoid exhausting browser SSE connections
- Viewport guard prevents splitting below min-width/min-height
- Only focused pane refreshes workstream list on SSE reconnect (prevents
  race when multiple panes disconnect simultaneously)
- Tab click focuses existing pane showing that ws in multi-pane mode
- Pointer events on drag handles for mouse + touch support
- Full a11y: ARIA roles/labels, keyboard nav in context menu, focus
  restoration, prefers-reduced-motion coverage

* fix: address PR #127 review feedback

- Add focusin handler so keyboard focus (Tab) updates focusedPaneId
- Context menu skips interactive elements (textarea, input, links,
  buttons) so native copy/paste and link context menus work
- Split handles get ARIA role=separator, aria-orientation, aria-valuenow,
  keyboard resizing (arrow keys, Home/End), and tabindex=0
- Enforce MAX_PANES limit in deserializeLayout to prevent corrupted
  localStorage from creating too many panes/SSE connections
- Update architecture.md to document split-pane layout
2026-03-18 18:03:46 -07:00
Patrick Buckley 9a2db63c07 fix: collector JWT expiry causes silent workstream data wipe (#126)
* fix: collector JWT expiry causes silent workstream data wipe

The console collector baked a one-time JWT snapshot into its httpx
client headers at startup. After 1 hour (JWT expiry), every poll to
server nodes returned 401. The error JSON was silently parsed as valid
empty data, wiping all workstream state while nodes still appeared
reachable — the cluster showed "10 nodes, 0 workstreams."

Root causes fixed:
- Collector: no auth baked into httpx.Client; per-request headers
  from ServiceTokenManager.token (auto-rotating) or static fallback
- Proxy: same pattern — proxy_client/proxy_sse_client created without
  auth headers; _proxy_auth_headers() injects fresh token per-request
- main(): static token snapshot only passed when no token_manager
  exists, preventing stale JWT from being stored anywhere
- _fetch_node: raise_for_status() before .json() so 401s throw
  instead of returning error JSON as "0 workstreams"
- Auth errors (401/403) logged at warning level for operator visibility

* fix: address PR #126 review — type annotation, regression tests, log messages

Tighten token_manager type from Any to ServiceTokenManager | None.
Add two regression tests verifying 401/403 poll responses preserve
existing workstream data and mark nodes unreachable. Fix misleading
log messages: "jwt_minted" → "token_manager_created" since
ServiceTokenManager mints lazily on first .token access.
2026-03-18 15:45:53 -07:00
Patrick Buckley e159837b74 fix: auto-titler SSE event + SSE reconnection after restart (#125)
* fix: auto-titler SSE event + SSE reconnection after restart

_generate_title() now calls self.ui.on_rename() after persisting the
title, so the tab bar, bridge, and console all update in real time.
Also handles multi-part (vision) content and replaces silent except
with log.debug.

SSE onerror handler now parses the workstreams response, replaces the
stale workstreams map, and switches to the first available workstream
if the current ws_id no longer exists (e.g. after server restart).
Previously it retried the stale ws_id forever.

* fix: address PR #125 review — avoid double reconnect + sync tab bar

Return immediately after switchTab/showDashboard on stale ws_id to
prevent scheduling a redundant connectContentSSE via setTimeout.
Always re-render tab bar after replacing the workstreams map so DOM
stays in sync even when currentWsId is still valid.
2026-03-18 15:14:14 -07:00
Patrick Buckley ec3454ee2e fix: wire resume_ws through console + expose max_ws in heartbeat (#124)
* fix: wire resume_ws through console + expose max_ws in heartbeat

Console create_workstream handler now reads resume_ws from the request
body and passes it to CreateWorkstreamMessage on all three dispatch paths
(pool, auto, explicit). Previously resume only worked via channel router
and direct CLI — the console layer never plumbed it through.

Server /health now includes max_ws from WorkstreamManager. Bridge reads
it on startup and includes it in heartbeat metadata so the console's
_pick_best_node gets accurate capacity instead of always defaulting to 10.
Collector also updates max_ws on subsequent heartbeats (not just discovery).

Schemas, Python SDK, TypeScript SDK, and OpenAPI specs updated. Test mocks
fixed for new max_workstreams property access in /health.

* fix: address PR #124 review — resume_ws tests + max_ws fetch on pre-set node_id

Add _fetch_server_metadata() so bridge reads max_ws from /health even
when node_id is pre-set (skipping _fetch_node_id). Without this, heartbeats
would advertise max_ws=10 regardless of actual server config.

Add 3 test cases verifying resume_ws flows through all three console
dispatch paths (directed, pool, auto-select).
2026-03-18 14:24:10 -07:00
renovate[bot] 5cbb832162 chore(deps): lock file maintenance 2026-03-18 13:20:49 -07:00
renovate[bot] 4e4ae2a91d chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.10.11 2026-03-18 13:20:47 -07:00
renovate[bot] c7d0bac638 chore(deps): update astral-sh/setup-uv digest to 37802ad 2026-03-18 13:20:44 -07:00
Patrick Buckley e86305c143 chore: bump version to 0.8.3 2026-03-17 17:06:00 -07:00
Patrick Buckley d0fc42195a chore: remove dead code and fix noisy JWT test warnings
Remove unused methods (ToolSearchManager.should_activate, get_all_tools),
dead attributes (_all_tools, _threshold), unused constant (DEFAULT_INTERVAL),
unused Scenario protocol class, and vestigial parameters (judge._evaluate_single
heuristic, SimEngine.simulate_llm_response turn_number). Lengthen JWT test
secrets to >= 32 bytes to suppress InsecureKeyLengthWarning from PyJWT.
2026-03-17 17:02:25 -07:00
Patrick Buckley 760321f7ee refactor: extract _resolve_capabilities and _without_tool helpers
Extract _resolve_capabilities() shared helper so _get_capabilities()
and _run_agent() use the same config-override logic instead of
duplicating inline. Add _without_tool() module-level helper to
deduplicate the tool-filtering listcomp.
2026-03-17 16:49:11 -07:00
Patrick Buckley 693e51f782 fix: address PR #119 review feedback
Add UI error notification and exc_info logging to run_one() exception
handler so tool failures are visible in the frontend. Apply config.toml
capability overrides when gating web_search in _run_agent(), matching
the pattern used by _get_capabilities().
2026-03-17 16:49:11 -07:00
Patrick Buckley ba07409724 fix: isolate parallel tool exceptions + gate web_search without backend
Two bugs: (1) an uncaught exception in one parallel tool call killed the
entire batch via pool.map(), losing all results including successful ones.
Wrap run_one() in try/except so failures return error strings instead of
propagating. (2) web_search was offered to local models even without a
Tavily API key — the model would attempt it, only to fail at execution
time. Filter web_search from _get_active_tools() and _run_agent() when
neither native support nor Tavily is available.

Closes https://github.com/turnstonelabs/turnstone/issues/117
2026-03-17 16:49:11 -07:00
Patrick Buckley c76a61841e fix: PR #118 round 2 — null-safe parser, docs, consistency
- Null-safe extraction for description, license, and compatibility in
  skill_parser.py — YAML bare keys (e.g. `description:`) no longer
  produce the literal string "None"
- Log warning on skill catalog storage failure instead of silent swallow
- Use `enabled == 1` in list_skills_by_activation for consistency with
  other prompt_templates queries in both storage backends
- Add parser tests for YAML null description, license, and compatibility
- Update governance.md: document runtime config editing on installed
  skills, two-column modal layout, SPDX license dropdown, origin badge
2026-03-17 16:09:06 -07:00
Patrick Buckley 341d2f604f fix: address PR #118 review feedback
- Regenerate OpenAPI snapshots (openapi-console.json) to include license
  and compatibility fields in SkillInfo/CreateSkillRequest/UpdateSkillRequest
- Omit version from create/update payloads when blank so server applies
  default "1.0.0" instead of storing empty string
- Push enabled_only + limit filters into list_skills_by_activation storage
  query (protocol, SQLite, PostgreSQL) instead of loading all rows and
  filtering in Python; session.py now passes enabled_only=True, limit=30
- License length cap ([:128]) was already applied in previous commit
2026-03-17 16:09:06 -07:00
Patrick Buckley 3f7f8495d6 feat: skills modal redesign + runtime config editing for installed skills
Redesigns the create/edit/view skill modal into a two-column spec manifest
layout (Identity/Manifest/Deployment | Skill Content) matching the Agent
Skills spec structure. Installed (readonly) skills can now have their runtime
config (model, temperature, token limits, enabled) edited independently of
the locked spec/content fields.

- Two-column spec layout with section headings (Identity, Manifest, Deployment,
  Skill Content); content textarea uses monospace font and fills the column
- h3 section headings for screen-reader nav; h3 UA stylesheet reset in CSS
- Runtime Config collapsible uses 3-column grid; license field is now a select
  of SPDX identifiers (MIT, Apache-2.0, GPL-3.0, AGPL-3.0, etc.)
- Origin badge (cyan) shows source URL for installed skills in view mode
- server.py: _SKILL_RUNTIME_CONFIG_FIELDS frozenset; readonly skills filter
  updates to config-only fields (spec fields silently dropped); audit action
  distinguishes skill.update.config from skill.update; license field capped
  at 128 chars in both create and update paths
- governance.js: spec fields disabled for readonly; config fields always
  editable; Save button shown for all skills (labeled "Save Config" when
  readonly); collapsible state reset between modal opens prevents state leak;
  esk-allowed-tools disabled state driven by auto_approve not readonly
- Tests: spec-only body on readonly skill → 400; config-only → 200 with
  spec fields unchanged; mixed body → config fields applied, spec dropped
2026-03-17 16:09:06 -07:00
Patrick Buckley dc464ac313 feat: Agent Skills standard compliance + frontend spec fields
Brings skills implementation into full compliance with agentskills.io:

Parser:
- Read `allowed-tools` (hyphenated, standard) only; stored as
  `allowed_tools` internally — no underscore fallback
- Reject consecutive hyphens in skill names
- Extract author/version from standard `metadata:` map with top-level
  fallback; null-safe (no "None" string for bare YAML keys)
- Truncate description at 1024 chars, compatibility at 500 chars (spec
  caps) with log warnings
- Lenient parsing mode (lenient=True) for cross-client import: sanitizes
  names, returns None on skip, malformed-YAML colon-value retry
- Type overloads: strict mode returns ParsedSkill, lenient returns
  ParsedSkill | None

Session:
- `<available-skills>` XML catalog in system messages for
  activation="search" skills (disabled ones filtered out, capped at 30)

Tool rename:
- `load_skill` tool → `skill` (JSON, session preparers/executors,
  approval labels, tests, docs)

Storage (migration 023):
- Add `license` and `compatibility` columns to prompt_templates
- skill_license / compatibility params on create_prompt_template across
  protocol, SQLite, PostgreSQL backends
- Add to SKILL_MUTABLE for update_prompt_template

API + server:
- SkillInfo, CreateSkillRequest, UpdateSkillRequest: license +
  compatibility fields
- Create/update/install endpoints extract and persist both fields
- Install endpoint maps parsed.license + parsed.compatibility from
  imported SKILL.md (previously discarded)
- _skill_to_response() includes both fields

Admin UI:
- Create + edit modals: version, license, compatibility fields
- Readonly (imported) skills: "edit" → "view" button, modal title
  "View Skill", all fields disabled, Save hidden, Cancel → "Close",
  collapsibles auto-expand, focus on Close button
- :disabled CSS for dark-theme modal inputs (bg-highlight, cursor
  not-allowed, dimmed text)
- Fix addEventListener stacking on auto-approve checkboxes → .onchange

SDK: license + compatibility on SkillInfo, CreateSkillRequest,
UpdateSkillRequest TypeScript interfaces

Docs: governance.md, judge.md, tools.md, README, diagram updated
2026-03-17 16:09:06 -07:00
Patrick Buckley 52d59cf7b7 chore: bump version to 0.8.2 2026-03-17 02:20:44 -07:00
Patrick Buckley 2dc885ab4d fix: output guard detects single secret-bearing env lines (#115)
* fix: output guard detects single secret-bearing env lines

The credential leak check required 3+ env-style lines before flagging.
A single AWS_SECRET_ACCESS_KEY=... line was missed. Now flags whenever
any env line has a secret-bearing key name (SECRET, KEY, TOKEN,
PASSWORD, CREDENTIAL), regardless of how many total env lines exist.

* fix: tighten env secret key matching, add tests

Tighten _RE_ENV_SECRET_KEY to word-boundary segments so MONKEY/TURKEY
don't false-positive. Use any() for short-circuit. Add test for single
secret line detection and substring false-positive prevention.
2026-03-17 02:19:22 -07:00
Patrick Buckley 14488f43e0 feat: metacognitive nudge on tool error — search memories for guidance
Add tool_error nudge type that fires when a tool returns an error,
prompting the model to search memories for prior feedback about the
tool or error pattern before retrying.

- Gated on nudges config (respects nudges=false)
- Only fires when memories exist (no noise on fresh workstreams)
- Broad error detection: Error*, *error:*, Command timed out, Unknown tool
- Nudge wording aligned to memory(action='search') convention
- Respects existing cooldown (5 min) and rate limiting
- 4 new tests
2026-03-17 02:06:10 -07:00
Patrick Buckley 90f2070146 chore: bump version to 0.8.1 2026-03-17 01:28:14 -07:00
Patrick Buckley 1e551830ea fix: allow deleting installed (readonly) skills
Readonly guard should prevent editing content, not uninstalling.
Remove readonly check from admin_delete_skill so batch-installed
skills can be individually deleted. Enable delete button in UI
for all skills regardless of readonly flag.
2026-03-17 01:26:44 -07:00
Patrick Buckley 84cc212ecd ui: tighten category and risk columns (100px -> 80px) 2026-03-17 01:26:44 -07:00
Patrick Buckley da4025d338 ui: skills table — category first, risk column, remove variables
- Move category column before name
- Remove variables column (rarely useful in table view)
- Add dedicated RISK column with scan badge, unicode shape indicators
  (checkmark/triangle/diamond/warning), and multi-line tooltip showing
  composite score and flagged axes from scan report
- Risk badge is keyboard-focusable (tabindex=0) with aria-label
- Unscanned skills show em-dash placeholder at 40% opacity
- Balanced grid: 100px 1.5fr 100px 120px
- Risk + category hidden on mobile (<700px)
2026-03-17 01:26:44 -07:00
Patrick Buckley 88085c29ff fix: normalize install response + review fixes
Address 5 Copilot review items + code review findings:

- Normalize install endpoint to always return envelope response:
  {installed: [...], skipped: [...], total: N} — eliminates dual
  response shape (single SkillInfo vs batch). Breaking change to
  install endpoint response, SDKs and OpenAPI spec updated.
- Add SkillInstallResponse + SkillInstallSkipped Pydantic models
- POST /resources spec now correctly documents response_code=201
- SQLite count_skill_resources_bulk chunks IN clause at 900 to stay
  under SQLITE_MAX_VARIABLE_NUMBER (999)
- Fix installDiscoveredSkill() JS handler for envelope response
- Add error key to 409 duplicate response for error handler compat
- Update Python SDK install_skill return type (dict, not SkillInfo)
- Add TypeScript SkillInstallResponse + SkillInstallSkipped types
- Regenerate openapi-console.json
- Update all install tests for envelope response shape
2026-03-17 01:26:44 -07:00
Patrick Buckley 3152667a0c fix: update test_skill_sources for 5-tuple _parse_github_url
_parse_github_url now returns (owner, repo, branch, path, branch_explicit).
Update all test unpackings and add assertions for branch_explicit.
2026-03-17 01:26:44 -07:00
Patrick Buckley 4b44d88401 fix: harden batch skill install — 7 review items + OpenAPI snapshot
- Race condition: wrap create_prompt_template in try/except, append
  to skipped on conflict instead of crashing
- HTTP timeout: per-request timeout (10s+5s connect) instead of shared
  15s pool; parallelize SKILL.md and resource fetches with semaphore
  (5 concurrent)
- Branch detection: return branch_explicit from _parse_github_url(),
  eliminate duplicated regex matching and type: ignore comments
- Content-length: check len(resp.content) after fetch instead of
  unreliable content-length header; add size check in batch path
- Rate limits: _check_rate_limit() inspects x-ratelimit-remaining,
  raises actionable error on 403, warns when remaining < 10
- Root resources: fix _find_resource_files skipping root-level
  resources like scripts/foo.sh for root SKILL.md
- resource_count: pass accurate count in update and install responses
- Regenerate openapi-console.json with new resource endpoints
2026-03-17 01:26:44 -07:00
Patrick Buckley 8957b9ce0e feat: batch install skills from multi-skill GitHub repos
When a GitHub repo URL has no root SKILL.md (monorepo pattern like
anthropics/skills), automatically scan the repo tree for all SKILL.md
files and install every discovered skill in one operation.

- Add fetch_skills_from_github_repo() — scans recursive tree, parses
  each SKILL.md, collects per-skill resources via shared helpers
- Extract _find_resource_files() and _fetch_resource_contents() to
  eliminate duplication between single and batch fetch paths
- Extend admin_skill_install to fall back to batch scanning when
  single-skill fetch returns 404
- Each skill gets a specific source_url pointing to its subdirectory
- Backward compatible: single-skill repos return same response shape
- Frontend handles both shapes with contextual toast messages
- Filter tree scan to URL path subtree when path is provided
- Cap at 50 skills per repo scan

Also addresses review feedback:
- Fix path prefix check (scripts/ not scriptsX/)
- Use count_skill_resources_bulk for single skill GET
- Add content field to SkillResourceInfo schema
- Fix OpenAPI spec paths ({path} not {path:path})
- Check r.ok on resource upload promises
- Preserve / in URL-encoded paths (split/map/join pattern)
- URL-encode path in Python SDK delete method
- Fix test_install_not_found to mock batch fallback
- Fix test_search_empty_results for required q param
- Narrow except clause to ValueError in batch parser
2026-03-17 01:26:44 -07:00
Patrick Buckley 28a6b0dd33 feat: skill resources — API, admin UI, runtime injection, and SDK
Complete the resource surface for skills (scripts/, references/, assets/):

- 4 admin API endpoints: list, get, create, delete skill resources
- Storage: delete_skill_resource_by_path + count_skill_resources_bulk
- Admin UI: resource count badge in skills table, resource sections in
  create/edit modals with add/delete, readonly guard for installed skills
- Runtime: _load_skills populates skill resources, _init_system_messages
  injects <skill-resources> catalog (inlined if <8KB)
- Python SDK: list/create/delete_skill_resource (async + sync)
- TypeScript SDK: listSkillResources, createSkillResource, deleteSkillResource
- Path traversal protection (normpath + .. rejection + null byte check)
- Block empty skill discover searches (frontend toast + backend 400)
- Rename MCP "Registry" tab to "Discover" for consistency with skills
- Move Skills + MCP Servers into new "Extensions" sidebar group
- 25 tests (7 storage, 16 API + 2 security)
2026-03-17 01:26:44 -07:00
Patrick Buckley 7bc17cc072 fix: populate func_args for all tools in intent judge evaluation
The heuristic engine was seeing empty {} for web_fetch, web_search,
watch, notify, task, and load_skill — only bash, file ops, and MCP
tools had their arguments forwarded. The judge could not pattern-match
on URLs, queries, commands, or messages for these tools.
2026-03-16 19:33:16 -07:00
Patrick Buckley 10a1800492 chore: bump version to 0.8.0 2026-03-16 19:22:17 -07:00
Patrick Buckley 1010f163f0 feat: load_skill built-in tool — model-driven skill discovery and act… (#112)
* feat: load_skill built-in tool — model-driven skill discovery and activation

Two-action tool: 'search' finds skills by multi-word query with substring
matching on name/description/tags/category (auto-approved, read-only);
'load' activates a skill by name via set_skill() (requires approval).

Guards: filters disabled skills from search + load; short-circuits when
skill is already active; approval_label includes skill name for granular
tool policies (load_skill__<name>); main session only (excluded from
sub-agents). Logs storage errors in search path.

25 tests covering registration, preparer validation, executor logic,
disabled/already-active edge cases, multi-word queries, approval labels.

* refactor: use BM25 relevance ranking for load_skill search

Replace substring matching with BM25Index from turnstone/core/bm25.py,
matching the pattern used by memory relevance and tool search. Handles
multi-word queries, term frequency, and document length normalization.

* fix: address copilot review — BM25 tags parsing, primary_key, test cleanup

- Parse JSON tags into space-separated text before BM25 indexing so
  individual tag terms match queries (was passing raw '["foo","bar"]')
- Add primary_key: "name" to load_skill.json for PRIMARY_KEY_MAP
- Remove dead resolve_workstream patch from test helper
- Update diagram: "substring match" → "BM25 ranking"
2026-03-16 19:20:19 -07:00
Patrick Buckley c28bfc1e58 feat: skill discovery — search and install skills from external sources (#111)
* feat: skill discovery — search and install skills from external sources

Add discovery UI and API for finding and installing skills from
skills.sh registries and GitHub repositories with one-click install,
SKILL.md frontmatter parsing, and security scan integration.

Core modules:
- skill_parser.py: ParsedSkill dataclass, parse_skill_md() with YAML
  frontmatter support (Anthropic + Hermes tag formats), name validation
- skill_sources.py: SkillsShClient (async search + resolve),
  fetch_skill_from_github (SKILL.md + bundled resource fetching with
  256KB cap, text extension filter, GitHub API tree traversal)

API:
- GET /v1/api/admin/skills/discover — search with installed annotation
  and scan_status for installed skills
- POST /v1/api/admin/skills/install — fetch, parse, duplicate check,
  create with origin="source" readonly=true, store resources, audit

Also fixes pre-existing bug where _skill_to_response omitted scan_status,
scan_report, scan_version fields — scan tier badges in the installed
skills table were silently empty despite data existing in storage.

Admin UI: pill toggle (Installed/Discover), discovery cards with scan
tier badges, GitHub import modal with proper focus trap/Escape/backdrop,
scoped selectors preventing MCP↔Skills cross-tab state corruption.

SDK: discover_skills() + install_skill() on Python (async+sync) and
TypeScript console clients.

48 new tests across 3 test files. All 2632 tests pass.

* fix: address copilot review — 404 vs 502, O(n) lookups, branch fallback

- SkillNotFoundError subclass: install returns 404 when SKILL.md is
  missing, 502 only for connectivity/upstream errors
- get_skill_by_source_url() + list_installed_skill_urls(): indexed
  storage lookups replace O(n) full-table scans with content blobs
- Default branch fallback: tries main then master when URL doesn't
  specify a branch
- Path normalization: strip trailing slash once, remove redundant
  candidate
- SDK install_skill() returns typed SkillInfo with response_model
- Tree size guard: skip resource tree if response >2MB
2026-03-16 18:42:32 -07:00
Patrick Buckley e71ea38953 feat: output guard data pipeline — persist assessments, SSE events, a… (#110)
* feat: output guard data pipeline — persist assessments, SSE events, admin UI

Complete the output guard pipeline: persist assessments for v2 calibration,
surface warnings in every UI layer, and add scan badges to admin skills tab.

Storage: migration 022 adds output_assessments table (flags, risk_level,
annotations, output_length, redacted — raw output never stored) and
scan_version column on prompt_templates. Three new protocol methods with
SQLite + PostgreSQL implementations.

Server/CLI: on_output_warning now persists assessments fire-and-forget.
CLI shows flags, annotations, and redaction notice. Session emits on_info
warning when high/critical scan_status skill is loaded.

MQ: OutputWarningEvent dataclass + bridge SSE forwarding.

Web UI: output_warning SSE handler with inline warning rendering
(role="alert" for accessibility), semantic risk colors.

Console admin: scan badges on skills list (dedicated scope-scan-* CSS with
green/yellow/red risk vocabulary), scan report breakdown in edit modal with
4-axis scores, POST /admin/skills/{id}/rescan endpoint, GET
/admin/output-assessments endpoint with date-filtered pagination.

Security fixes: ReDoS in connection string regex ([^@\s]+ → [^:@\s]+),
negative limit bypass in all admin endpoints (max(1, ...)), to_dict()
excludes sanitized output by default.

False-positive fixes: credentials pattern anchored to path context,
env secret key check restricted to key portion only.

* fix: address PR #110 review — list redaction, test fixture, OpenAPI snapshot

Per-part redaction: evaluate each text part independently in structured
output instead of joining all parts and replacing only the first one.

Fix test annotations default from "{}" to "[]" matching schema.

Regenerate TypeScript OpenAPI snapshots for new admin endpoints.
2026-03-16 17:39:24 -07:00
Patrick Buckley 5378b33641 feat: output guard — evaluate tool results before they enter context (#109)
* feat: output guard — evaluate tool results before they enter context

Add turnstone/core/output_guard.py — a time-budgeted heuristic that
evaluates tool execution results after execution but before they
enter the conversation context window.

Priority-ordered detection (5s budget, highest priority first):
1. Prompt injection: override phrases, role injection, instruction
   override markers, meta-injection patterns
2. Credential leakage: API keys (OpenAI/GitHub/AWS/Google), PEM
   private key blocks, connection strings, .env secret format
3. Encoded payloads: script data URIs, hex shellcode sequences
4. Adversarial URLs: cloud metadata endpoints, credential query params
5. System info disclosure: private IPs, sensitive file paths

Annotates and optionally redacts (credentials → [REDACTED:<type>]).
Does NOT gate — surfaces warnings via on_output_warning callback.

Integration:
- Wired into session.py tool result loop via _evaluate_output()
- JudgeConfig gains output_guard + redact_secrets fields (both default true)
- SessionUI protocol gains on_output_warning callback
- 25 compiled regex patterns, pure function, no I/O

29 tests covering all detection categories, benign output false
positive checks, credential redaction, and time budget behavior.

* fix: address PR #109 review — protocol, config, and guard fixes

Copilot review feedback:
- Replace _CLEAN singleton with _clean() factory to prevent mutable
  shared state (OutputAssessment has list fields)
- Remove redundant second _CREDENTIAL_PATTERNS loop in _check_credentials
- Evaluate text parts of list outputs (images) not just string outputs
- Wire output_guard + redact_secrets through ConfigStore settings
  registry and _build_judge_config() so operators can configure via
  admin Settings tab
- Remove --no-output-guard CLI flag claim from docs (use Settings tab)

Typecheck fix:
- Add on_output_warning to all SessionUI implementations: NullUI
  (eval, 5 test files), WebUI (server — emits SSE event), TerminalUI
  (CLI — ANSI colored warning), RecordingUI, FakeUI
2026-03-16 16:22:10 -07:00
Patrick Buckley 9b605f81a3 feat: skill scanner — evaluate SKILL.md content at install time
Add turnstone/core/skill_scanner.py — a production content scanner
that evaluates skill risk across four axes:

1. Content risk: command execution, external downloads, credential
   handling, data exfiltration, eval/exec, sudo, browser automation
2. Supply chain risk: pipe-to-shell, transitive installs, obfuscation,
   download-exec chains, executable URLs from untrusted domains
3. Vulnerability risk: prompt injection (E004), insecure credential
   handling (W007), third-party content exposure (W011)
4. Declared capability risk: parsed from allowed_tools field —
   Bash(*) is high, Bash(git:*) is low, read-only tools are safe

Composite score with equal 25% weights per axis. Floor rule: any
single axis at critical forces composite to at least medium tier.

Wired into both SQLite and PostgreSQL storage backends:
- scan_skill() runs at create_prompt_template time
- Re-scan triggers on update when content or allowed_tools change
- Results populate the existing scan_status and scan_report columns
- Silent failure on scanner errors (never blocks skill creation)

Scanner helper factored into _utils.py (shared across backends).
23 unit tests covering tier classification, capability scoring,
negation filtering, floor rule, serialization, and trusted domains.
2026-03-16 15:45:21 -07:00
Patrick Buckley f05e6bddad feat(judge): enrich heuristic rules from 23 to 36 (#107)
* feat(judge): enrich heuristic rules from 23 to 36

Add 13 new pattern-based rules to the intent validation heuristic,
calibrated from analysis of 25K public agent skill security audits
across three independent auditors.

New critical: download-then-execute chains.
New high: browser+data export, transitive installs from untrusted
sources, control plane mutations (crontab, systemctl).
New medium: content ingestion pipelines (curl|python3), interpreter
execution (python3 script.py), cloud CLI mutations (az/gcloud/aws/
kubectl/terraform create/delete/destroy).
New low: tool_search, read_resource, web_search.

Fixes: crontab -l no longer false-positives, systemctl stop/disable
now flagged, az/gcloud subcommand patterns work correctly.

* fix(judge): address PR #107 review feedback

- content-ingestion: narrow second pattern to specific interpreters/
  processors (python3, node, ruby, perl, php, jq) instead of any word.
  Prevents false positives on read-only downstream (wget -O - | head).
- cloud-infra-mutation: split kubectl into its own pattern with specific
  verbs (apply, create, delete, scale, rollout, drain, cordon) to avoid
  false positive on resource types (kubectl get deploy).
- cloud-infra-mutation: split terraform/pulumi to specific verbs only
  (apply, destroy, import) — terraform plan no longer matches.
- control-plane-mutation: exclude -h and -V flags from crontab pattern
  alongside existing -l exclusion.
- Add 35 heuristic rule tests covering all 13 new rules with positive
  matches and negative (false-positive prevention) cases.
2026-03-16 15:09:36 -07:00
Patrick Buckley 75eda9a096 feat: unified skills system — merge prompt templates + workstream tem… (#106)
* feat: unified skills system — merge prompt templates + workstream templates

Evolves prompt_templates into a first-class skills entity and merges
workstream templates into the same model, collapsing two concepts into
one.

Migration 021: 21 new columns on prompt_templates (skills metadata,
security scan fields, session config from WS templates), skill_resources
table for bundled files, skill_versions table for auto-snapshot version
history. Data migration converts existing WS templates into skills with
name collision handling, migrates version history, renames workstreams
and scheduled_tasks columns, cleans orphaned permissions, drops old
tables.

Key changes:
- All public interfaces renamed: templates → skills (API, CLI, SDK, UI)
- Session config (model, temperature, token_budget, auto_approve, etc.)
  now lives on the skill and is applied at workstream creation
- /skill slash command, set_skill() API, --skill CLI flag
- BM25 skill search via SkillSearchManager for activation="search" skills
- Admin UI: Skills tab with collapsible Session Config section,
  description subtitles, activation/origin/MCP badges, pagination
- Shared validation helper (_parse_skill_session_config) for DRY CRUD
- Version history with auto-snapshot on every edit + API endpoint
- Cascade delete (resources + versions) on skill removal
- Security: range validation, activation allowlist, fail-closed enabled
  check, duplicate name 409, readonly guard, JSON validation
- 77 new tests across storage, runtime, search, API integration, and
  migration behavior verification (2521 total)

* fix: address Copilot review + rename admin.templates → admin.skills

- Skip skill lookup when resume_ws is set (avoids spurious 400)
- Fix _applied_skill_version mismatch (1 in both workstreams table and session)
- Remove stale template field from MQ protocol diagram
- Rename admin.templates permission to admin.skills everywhere (runtime,
  frontend, tests, docs) with migration step for persisted role data
- Fix stale /api/templates references in docs and diagrams
- Update docstrings/comments for skills terminology

* fix: address Copilot round 2 — skill version lineage + stale doc refs

- Compute actual skill version from skill_versions count (not hardcoded 1)
- Use same version in both workstreams table and session metadata
- Fix response payload example: "templates" → "skills" key
- Fix "Each template summary" → "Each skill summary"
2026-03-16 14:47:06 -07:00
renovate[bot] 4c00d71150 chore(deps): lock file maintenance (#105)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-16 02:18:02 -07:00
Patrick Buckley 80e1924d7f feat: enable prompt caching for Anthropic and OpenAI providers (#104)
* feat: enable prompt caching for Anthropic and OpenAI providers

Activate automatic prompt caching on both LLM providers to reduce input
token costs on multi-turn conversations. Anthropic gets cache_control:
ephemeral (90% savings on cache hits), OpenAI GPT-5.x gets 24h extended
cache retention (free). Cache metrics flow end-to-end through the entire
data pipeline: provider → session → server SSE → MQ protocol → storage →
Prometheus metrics → admin Usage tab.

- AnthropicProvider: top-level cache_control on all requests, extract
  cache_creation_input_tokens and cache_read_input_tokens from streaming
  and non-streaming responses
- OpenAIProvider: prompt_cache_retention=24h for GPT-5.x, extract
  cached_tokens from usage.prompt_tokens_details
- UsageInfo: new cache_creation_tokens and cache_read_tokens fields
- Migration 020: add cache columns to usage_events table
- Storage: record_usage_event and query_usage updated (sqlite + pg)
- Metrics: turnstone_tokens_total{type="cache_creation|cache_read"}
- Server: on_status passes cache tokens to SSE, storage, and metrics
- MQ: StatusEvent carries cache fields through bridge
- SDKs: Python and TypeScript StatusEvent types updated
- OpenAPI: UsageBreakdownItem schema includes cache fields
- Console UI: Usage tab shows cache write/read as secondary readouts
  with visual separator, dimmed when zero
- 16 new tests, docs and 3 diagrams updated

* fix: address Copilot review feedback

- Fix MQ protocol diagram clipping by switching to vertical package
  layout (inbound on top, outbound below) with package aliases
- Regenerate OpenAPI snapshots to include cache_creation_tokens and
  cache_read_tokens on UsageBreakdownItem
- Replace fragile MagicMock(spec=[]) + del pattern with
  types.SimpleNamespace in cache metrics missing-attributes test
2026-03-16 01:59:55 -07:00
Patrick Buckley 471bf89c8b Merge pull request #101 from turnstonelabs/feat/mcp-registry
feat: MCP Registry integration — discover and install servers from th…
2026-03-15 22:01:25 -07:00
Patrick Buckley 35785d3a0e fix: address round 2 Copilot feedback
- Pass table_name to op.drop_index in migration 019 downgrade for
  dialect portability
- Fix dedup comment accuracy (first occurrence wins, not highest version)
- Re-render registry cards on install failure to reset stuck
  "Installing..." button state
2026-03-15 21:53:07 -07:00
Patrick Buckley 2ff0cd8240 Merge pull request #103 from turnstonelabs/renovate/lock-file-maintenance
chore(deps): lock file maintenance
2026-03-15 21:47:29 -07:00
Patrick Buckley f9f0ff0b53 Merge pull request #102 from turnstonelabs/renovate/github-actions
chore(deps): update softprops/action-gh-release digest to 153bb8e
2026-03-15 21:47:26 -07:00
Patrick Buckley df8a36ced4 fix: add timeout to MCP server disconnect to prevent hung removals
stack.aclose() on a stuck streamable-http transport hangs indefinitely,
causing 50% CPU on all nodes when removing a broken remote server via
reconcile_sync. Wrap with asyncio.wait_for(timeout=10s) so cleanup
proceeds even if the transport refuses to close cleanly.
2026-03-15 21:42:05 -07:00
Patrick Buckley ef6cac6428 fix: address review feedback and add sync-pending indicator
Review fixes:
- Rename query param from `q` to `search` across endpoint, frontend,
  SDKs, OpenAPI spec, docs, and tests to match upstream registry API
- Validate variables/env/headers are dicts in install endpoint (400 on
  malformed input instead of 500)
- Block javascript: and unsafe URL schemes on repo and website links
  rendered from registry data (XSS prevention)
- Add roving tabindex to Servers/Registry pill toggle for correct
  keyboard focus behavior
- Add noreferrer to website link in detail modal

Sync-pending indicator:
- "Sync to Nodes" button pulses yellow after create/edit/delete/import
  to alert admin that nodes have unseen changes
- Clears after successful sync
- Reduced-motion safe
2026-03-15 21:41:02 -07:00
renovate[bot] f4c3b3a9c4 chore(deps): lock file maintenance 2026-03-16 04:11:10 +00:00
renovate[bot] d06db3feee chore(deps): update softprops/action-gh-release digest to 153bb8e 2026-03-16 04:10:39 +00:00
Patrick Buckley 50544c0d1b feat: MCP Registry integration — discover and install servers from the official registry
Backend: standalone MCPRegistryClient (httpx async) queries the official MCP
Registry API (registry.modelcontextprotocol.io, v0.1). Two new console admin
endpoints: GET /v1/api/admin/mcp-registry/search (proxy with installed-status
annotation, dedup, uninstallable server filtering) and POST
/v1/api/admin/mcp-registry/install (auto-reloads all cluster nodes). Migration
019 adds registry_name/version/meta columns to mcp_servers with partial unique
index. Configurable registry URL via mcp.registry_url setting for
enterprise/private registries. resolve_install_config() handles both remote
(streamable-http) and package (npm→npx, pypi→uvx) installs. Pydantic models,
OpenAPI spec, Python + TypeScript SDK methods.

Frontend: unified MCP admin tab with Servers/Registry pill toggle (ARIA
tablist). Servers view: tri-state source badges (CONFIG/MANUAL/REGISTRY).
Registry view: search bar with type filter (remote/npm/pypi), auto-browse on
tab switch, result cards with source-type badges and repo links, one-click
install for zero-config remotes, install modal with dynamic form for servers
needing env vars/headers/URL variables. Package install warning banner.
Post-install status polling with connection/error feedback toasts. Trust
notice banner linking to the official registry.

Safety: 30s connect timeout on streamablehttp_client and session.initialize()
prevents hung connections from blocking the MCP event loop indefinitely.
Required-only headers in install config prevents empty auth headers from
causing silent 401s.

71 new tests (registry client, API endpoints, storage columns). Docs:
dedicated docs/mcp-registry.md, updated api-reference, architecture, console,
sdk, settings docs. Updated MCP architecture diagram.
2026-03-15 21:09:27 -07:00
Patrick Buckley d1c484737f fix(ci): regenerate lockfile for v0.7.0 and exclude local package from pip-audit
uv lock --check fails after version bump because the lockfile is stale.
pip-audit --strict fails because turnstone 0.7.0 isn't on PyPI yet.

Fix: regenerate uv.lock, and audit only third-party deps via
uv export --no-emit-project piped to pip-audit -r.
2026-03-15 17:28:13 -07:00
Patrick Buckley c2c6689a8e chore: bump version to 0.7.0 2026-03-15 17:15:27 -07:00
Patrick Buckley f5af4875ba fix: surface MCP server errors in admin UI instead of silent logging (#100)
* fix: surface MCP server errors in admin UI instead of silent logging

get_server_status() hardcoded error="" — connection and refresh failures
were logged but never surfaced to the admin panel.

Added _last_error dict to MCPClientManager: set on failure (connect,
refresh, periodic refresh, notification handler), cleared on success,
cleaned up on remove. Read in get_server_status().

Admin UI: error tooltip on list row status span, error text in red
in detail modal per-node list. Schema already had the field.

6 new tests for error tracking lifecycle.

* feat: add turnstone_mcp_server_errors Prometheus gauge

Exposes the count of MCP servers currently in error state via
/metrics for alerting and reliability tracking.

* fix: address copilot review — sanitize error strings, clear on notification success

- Add _set_error() helper: strips newlines, truncates to 256 chars
- All error-setting sites now use _set_error() for consistent sanitization
- Notification handler clears _last_error on successful refresh (fixes
  stale error for push-notification servers that skip _periodic_refresh)
2026-03-15 17:12:13 -07:00
Patrick Buckley 0d77d65266 test: add OIDC handler integration tests (22 tests) (#99)
TestClient-based integration tests for the 4 OIDC HTTP endpoints: authorize, callback, admin list identities, admin delete identity.

Uses real SQLite storage with mocked external OIDC calls (exchange_code, validate_id_token, provision_oidc_user) to exercise the full handler→module→storage contract. Covers happy paths, error flows, rate limiting, JWKS key rotation retry, and state expiration.
2026-03-15 16:44:44 -07:00
Patrick Buckley c11991819e fix: apt-get upgrade in Dockerfile to resolve CVE-2026-0861
Trivy scan fails on HIGH for libc-bin/libc6 (2.41-12+deb13u1).
The fix (2.41-12+deb13u2) is available in Debian repos but the
base python:3.14-slim image hasn't been rebuilt yet. Adding
apt-get upgrade pulls in all pending security patches at build time.
2026-03-15 16:40:03 -07:00
Patrick Buckley fc948d711d fix: use pgautoupgrade for seamless postgres major version upgrades
Replaces postgres:18-alpine with pgautoupgrade/pgautoupgrade:18-alpine
in compose.yaml. Sets PGDATA=/var/lib/postgresql/data so pgautoupgrade
detects existing pg17 data and runs pg_upgrade automatically on first
start. No manual migration needed.

Also increases healthcheck start_period to 30s to accommodate the
one-time upgrade process.
2026-03-15 16:26:19 -07:00
renovate[bot] d9722f3578 chore(config): migrate config .github/renovate.json (#97)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-15 16:24:39 -07:00
renovate[bot] f494553020 chore(deps): update helm release redis to v25 (#93)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-15 15:58:41 -07:00
renovate[bot] c766c81f25 chore(deps): update helm release postgresql to v18 (#92)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-15 15:58:38 -07:00
renovate[bot] 3b746fb28d chore(deps): update docker images (#90)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-15 15:58:34 -07:00
renovate[bot] b82fa4923c chore(deps): lock file maintenance (#94)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-15 15:56:18 -07:00
renovate[bot] 4ac316dc0e chore(deps): update github actions (#91)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-15 15:56:16 -07:00
renovate[bot] 387ef06da7 chore(deps): update helm release redis to ~20.13.0 (#89)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-15 15:56:13 -07:00
renovate[bot] e4e2200c33 chore(deps): update helm release postgresql to ~16.7.0 (#88)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-15 15:56:11 -07:00
renovate[bot] 8b94d553e4 chore(deps): update docker images (#87)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-15 15:55:53 -07:00
renovate[bot] cf16724137 chore(deps): pin dependencies (#86)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-15 15:53:03 -07:00
Patrick Buckley 22402e89de feat: add dependency management with Renovate, uv.lock, and security … (#83)
* feat: add dependency management with Renovate, uv.lock, and security scanning

Adds automated dependency update detection and vulnerability scanning
across all dependency layers (Python, vendored JS, TypeScript SDK, Docker,
GitHub Actions).

- Renovate config with 10 package groups and custom regex managers for
  vendored JS (KaTeX, Highlight.js, Mermaid) tracking via npm registry
- uv.lock for reproducible builds (80 packages)
- Dockerfile switched to uv sync --frozen with layer caching
- CI: pip-audit (via lock file), npm audit, lock-check jobs
- CI: lint job uses pre-commit for ruff version consistency
- Docker security scan workflow (weekly Trivy, HIGH/CRITICAL)
- Helper script for vendored JS library updates

* fix: resolve CI failures and address review feedback

- Update pre-commit hooks: ruff v0.9.10 -> v0.15.6 (fixes deprecated
  UP038 rule), mypy v1.14.1 -> v1.19.1
- Add per-file-ignore for N802 on sandbox.py (ast visitor convention)
- Fix pip-audit: install into uv venv so uv run can find it
- Pin uv-version in CI to match lock file generator (0.9.18)
- Upgrade vitest ^2.0 -> ^4.1 to fix esbuild GHSA-67mh-4wv8-2f99
- Vendored JS script: use grep -rl for auto-discovery of version refs
  (catches docs/architecture.md), fix LICENSE comment, portable grep
2026-03-15 15:46:48 -07:00
Patrick Buckley e7743fd079 feat: per-tool "Always" approve instead of blanket auto-approve (#82)
* feat: per-tool "Always" approve instead of blanket auto-approve

Interactive "Always" button now adds specific tool names to
auto_approve_tools instead of setting blanket auto_approve=True.
Only the tool types in the current batch are auto-approved going
forward — new tool types still prompt for approval.

Server uses approval_label (with func_name fallback) matching the
existing approve_tools() lookup. CLI and bridge use func_name.
Budget override excluded from all paths.

UI: dashed border on Always button signals persistent action,
dynamic tooltip/badge show tool names, aria-label for screen
readers, focus-visible outline fix, overflow-wrap on badge.

Bridge: seeds with DEFAULT_SAFE_TOOLS on first "always" to avoid
losing existing safe-tool auto-approvals.

16 new tests (10 unit + 6 TestClient integration). Updated tool
pipeline diagram and docs.

* fix: address copilot review — filter errored items, hide Always on budget-only

- Server/bridge/JS: add `not it.get("error")` filter so policy-denied
  items aren't added to auto_approve_tools
- Hide Always button when no eligible tools (budget-override-only batch)
- Docs: clarify CLI/bridge use func_name (coarser MCP granularity)
2026-03-15 15:25:40 -07:00
Patrick Buckley 27349e1c13 refactor: move bridge content buffer to server-side single source of truth
Eliminate dual accumulation by piggybacking assistant response text on
the server's ws_state:idle SSE event.  The bridge no longer maintains
its own _ws_content_buffer — it reads content directly from the idle
event and passes it through to TurnCompleteEvent unchanged.

Server-side: WebUI accumulates tokens in on_content_token(), joins and
includes in the idle broadcast, then resets (with 256 KB cap).

Downstream consumers (Discord bidi DM forwarding, catch-up) are
unaffected — TurnCompleteEvent.content is still populated.
2026-03-15 15:04:22 -07:00
Patrick Buckley 37a48bb30d fix: validate scope_id requires scope in memory API (#80)
* fix: validate scope_id requires scope in memory API

Prevent misleading scope_id usage: reject scope_id with global scope,
require scope when scope_id is provided, require scope_id for
workstream/user scopes on writes. Belt-and-suspenders guard in storage
backends ignores scope_id when scope is empty.

* fix: strip whitespace in scope validation, relax user scope_id requirement

Address Copilot review: .strip() whitespace-only values in all three
validation helpers; SaveMemoryRequest no longer requires scope_id for
user scope since the server auto-resolves it from auth context.
2026-03-15 14:28:03 -07:00
Patrick Buckley 730f5704ff fix: inject prompt template guardrails into plan agent system message (#79)
* fix: inject prompt template guardrails into plan agent system message

Safety/behavioral templates were silently bypassed by the plan agent,
which only used _PLAN_IDENTITY. Now _plan_system_content() prepends
_template_content (when present) so admin-configured guardrails apply
to both _exec_plan and _refine_plan, matching the task agent pattern.

* fix: address Copilot review — log truncation, comment clarity, test robustness

- Log warning on template truncation in _plan_system_content() for
  consistency with _init_system_messages()
- Clarify comment that prior plan pairs (not general history) are forwarded
- Use ChatSession._PLAN_IDENTITY for index assertions instead of substring
2026-03-15 14:24:10 -07:00
Patrick Buckley 9b3b1c1ddd fix: reorder new-workstream modal so Task is the primary field (#77)
* fix: reorder new-workstream modal so Task is the primary field

Users were typing their prompt into the Name field (first text input,
auto-focused) and leaving Task empty, creating idle workstreams. Move
Task textarea to the top of the form, auto-focus it, and add
Ctrl/Cmd+Enter submit shortcut. Accessibility fixes: cancel button
focus-visible, label-hint contrast raised to WCAG AA, platform-aware
keyboard hint, Ctrl+Enter added to shortcuts overlay.

* fix: Enter on Cancel button no longer triggers submit

Copilot review caught that pressing Enter while focused on the Cancel
button bypassed native click and called submitNewWs(). Skip the
Enter-to-submit handler for BUTTON elements so native activation fires.
Also make keyboard shortcuts overlay platform-aware (Ctrl vs ⌘).
2026-03-15 14:23:57 -07:00
Patrick Buckley a9ed8a954b fix: convert _pending_nudge from single-slot to list for defensive correctness
Every append site immediately drains via _init_system_messages(), so this
is defensive — ensures multiple nudges survive if the drain flow is ever
refactored to batch calls.
2026-03-15 14:17:34 -07:00
Patrick Buckley 1efcbcf2ba perf: parallelize _collect_mcp_status and _notify_nodes_mcp_reload wi… (#73)
* perf: parallelize _collect_mcp_status and _notify_nodes_mcp_reload with asyncio.gather

Both functions queried cluster nodes sequentially, making latency
O(N × timeout). Use asyncio.gather to query all nodes concurrently,
matching the existing admin_list_watches pattern. Also reuse the
shared proxy_client instead of creating throwaway httpx clients per
node, and add debug logging on MCP status fetch failures.

* perf: bound node fan-out concurrency and improve debug logging

Add _NODE_FAN_OUT_LIMIT (50) semaphore to all three gather fan-out
sites (_collect_mcp_status, _notify_nodes_mcp_reload, admin_list_watches)
to cap concurrent outbound connections below the httpx pool limit,
leaving headroom for other proxy traffic at 1000-node scale.

Add exc_info=True to all debug log calls for actionable diagnostics.

* test: add unit tests for _collect_mcp_status and _notify_nodes_mcp_reload

11 tests covering success, non-200, missing URL, exceptions, empty
cluster, and mixed multi-node scenarios for both fan-out helpers.
2026-03-15 13:54:49 -07:00
Patrick Buckley 1d36d80fe5 fix: reduce metacognition false positives with strong/weak pattern tiers (#76)
* fix: reduce metacognition false positives with strong/weak pattern tiers

Correction detection: split "no" handling — "no," and "no." are strong
(always fire), "no <word>" uses an allowlist of correction-context words
(pronouns, demonstratives, verbs) instead of a blocklist. Phrases like
"no problem", "no worries", "no rush" are excluded automatically.

Completion detection: move most patterns to weak tier, gated by message
length (<80 chars) and absence of continuation markers ("?", "can you",
"but", "now", "please", etc.). "thanks for X" excluded at regex level.
Strong tier (always fire): "that's all", "lgtm".

* fix: align allowlist comment with implementation (include articles)
2026-03-15 13:53:39 -07:00
Patrick Buckley e603a6a7d1 fix(oidc): pin redirect URI via TURNSTONE_OIDC_REDIRECT_BASE env var (#74)
* fix(oidc): pin redirect URI via TURNSTONE_OIDC_REDIRECT_BASE env var

OIDC redirect_uri was derived from the request Host header, which is
unreliable behind reverse proxies. Add TURNSTONE_OIDC_REDIRECT_BASE
(env var / config.toml) to pin the externally-reachable origin.

Extract _build_oidc_redirect_uri() helper to deduplicate the authorize
and callback handlers. Validate redirect_base at load time (must be
scheme://host[:port], rejects paths/query strings/invalid schemes).

* fix(oidc): reject redirect_base with missing hostname

Addresses Copilot review: values like `https://` or `https://:443`
passed validation but would produce invalid redirect URIs.

* fix(oidc): reject redirect_base with userinfo or invalid port

Addresses Copilot round 2: urlparse silently accepts user:pass@host
and non-numeric ports. Now explicitly rejects both.
2026-03-15 13:52:05 -07:00
Patrick Buckley 2e95f2ac73 test: add scope coverage for internal MCP/config reload endpoints (#75)
* test: add scope coverage for internal MCP/config reload endpoints

Verify required_scope() returns "approve" for _internal endpoints
across all access patterns (bare, /v1/-prefixed, console proxy with
and without /v1/), plus a GET negative test confirming only POST is
elevated. Closes the "internal endpoints accept read scope" item in
PROGRESS.md — the endpoints were already in APPROVE_PATHS.

* test: add config-reload v1/proxy scope tests per review feedback

Add /v1/-prefixed and console proxy variants for config-reload to
match the mcp-reload coverage, as flagged by Copilot review.
2026-03-15 13:45:35 -07:00
Patrick Buckley 5f27ed9fca feat: OIDC identity management inline in Users admin tab (#72)
Expandable user rows in the console Users tab reveal OIDC identities
linked to each user. Issuer badge, truncated subject, email, relative
last-login time, and unlink action with confirmation modal + audit trail.

Keyboard accessible (tabindex, Enter/Space, aria-expanded, focus-visible).
In-place refresh after unlink (no close/reopen flicker). Audit captures
user_id before delete. Mobile responsive (3-column at <700px).
Reduced-motion support. 2 new admin API endpoints reusing admin.users
permission and existing storage methods.
2026-03-15 03:52:42 -07:00
Patrick Buckley 20df7b3034 feat: OIDC SSO authentication with PKCE, auto-provisioning, and role … (#71)
* feat: OIDC SSO authentication with PKCE, auto-provisioning, and role mapping

Add OpenID Connect as a fourth authentication method, enabling single sign-on
via any OIDC provider (Okta, Azure AD, Google, Keycloak). Opt-in via env vars
(TURNSTONE_OIDC_ISSUER, CLIENT_ID, CLIENT_SECRET).

Security:
- Authorization Code Flow with PKCE (S256)
- State/nonce parameters with database-backed pending store (multi-node safe)
- JWKS signature validation with async fetch + key rotation retry
- Algorithm allowlist from JWKS key (not token header) prevents confusion
- Identity matching exclusively by (issuer, sub) — prevents account takeover
- password_enabled=false enforced server-side, not just UI
- Rate limiting on both authorize and callback endpoints
- OIDC users get "!oidc" password sentinel (bcrypt rejects naturally)
- ID token validated for iss, aud, exp, nonce

Features:
- Auto-provisioning with username deduplication on first login
- Claim-based role mapping with IdP demotion propagation (revokes stale roles)
- "Continue with [Provider]" SSO button on login page
- OIDC-only mode hides password form
- Setup wizard required before OIDC login (admin bootstrap)

Storage: migration 018 (oidc_identities + oidc_pending_states tables),
8 new protocol methods on both SQLite and PostgreSQL backends.
66 new tests (2273 total).

* fix: address PR #71 review feedback (18 items)

Bugs fixed:
- OIDC success redirect now fetches permissions via new /auth/whoami
  endpoint before completing login (fixes permission-gating in UI)
- Remove double decodeURIComponent on oidc_error (URLSearchParams
  already decodes; extra call throws on stray %)
- Authorize rate limiter returns redirect instead of JSON 429
  (endpoint reached via browser navigation, not fetch)
- Lazy JWKS fetch in callback when startup discovery failed (IdP
  recovery without restart)
- Startup exception handlers now log with exc_info=True
- PostgreSQL pop_oidc_pending_state uses DELETE...RETURNING for
  true atomicity (eliminates TOCTOU)

Behavior:
- New OIDC users without role mapping get builtin-viewer by default
  (assigned_by="oidc-default", not revoked by role sync)

Documentation fixes:
- Role mapping: sync semantics (add + revoke stale), not "additive only"
- PASSWORD_ENABLED=false blocks ALL password logins including admin
- Algorithm: asymmetric allowlist, not per-key derivation
- PlantUML diagram updated for role revocation

API spec fixes:
- Removed error_codes=[302] from callback (302 is success redirect)
- Added /auth/whoami to both server + console specs
- Regenerated TypeScript SDK OpenAPI snapshots (23 + 51 paths)

* fix: address PR #71 round 2 review feedback (10 items)

Rate limiting:
- Authorize endpoint now calls record() after check() so the rate
  limiter actually counts attempts (was a no-op before)

OIDC resilience:
- Split startup try/except: discovery failure disables OIDC, JWKS
  prefetch failure leaves OIDC enabled for lazy retry on first login
- JWKS unavailable message changed to "temporarily unavailable"
  (was misleadingly "not configured")
- create_oidc_pending_state raises on collision instead of OR IGNORE
  (prevents silent insert drop on state collision)
- SQLite pop_oidc_pending_state uses BEGIN IMMEDIATE for write lock
  (eliminates TOCTOU race)

Frontend:
- OIDC error display deferred 300ms so showLogin()'s async status
  fetch doesn't clear it via _switchMode → _clearError

API spec:
- OIDC authorize/callback endpoints now declare response_code=302
- Added AuthWhoamiResponse Pydantic model for /auth/whoami
- Regenerated TypeScript SDK OpenAPI snapshots

Documentation:
- Diagram: JWKS "cached at startup, refreshed on-demand" (was "hourly")
- Added TODO(tech-debt) comments on Host header redirect_uri sites
2026-03-15 03:44:18 -07:00
Patrick Buckley 68c991fbdd fix: restore safe HTML element rendering and suppress plantuml warning (#70)
* fix: restore safe HTML element rendering and suppress plantuml warning

- Add safe HTML tag allowlist in inlineMarkdown: br, hr, kbd, mark,
  sub, sup, ins, wbr, details, summary, abbr, small, u, s
  (attribute-free only — XSS safe, tags with attributes stay escaped)
- Add <details>/<summary> block-level protection pass with recursive
  markdown rendering of inner content
- Add plantuml to _NO_HIGHLIGHT_LANGS (suppresses highlight.js warning
  for unsupported language)
- CSS for details (collapsible, overflow hidden), kbd (mono font,
  key style), mark (yellow-glow token for theme adaptation)

* fix: restrict safe tags to inline-only, broaden details regex

- Remove hr, details, summary from inline _SAFE_TAGS allowlist (they
  are block-level and produce invalid HTML inside <p> wrappers)
- Make <details> regex newline-optional so same-line
  <details><summary>Title</summary> patterns are captured
2026-03-15 02:34:03 -07:00
Patrick Buckley 376da3d084 feat: prompt template tech debt — tests, read-only endpoints, double-… (#67)
* feat: prompt template tech debt — tests, read-only endpoints, double-load fix, server creation modal

Close test coverage gaps for prompt templates:
- Resume with deleted template: verifies graceful degradation (template_content=None, warning logged)
- Threading safety: concurrent set_template/init_system_messages with no race conditions
- Factory passthrough: template kwarg propagation through WorkstreamManager.create()

Add read-only template listing endpoints (read scope, no content exposed):
- GET /v1/api/templates — prompt template summaries (name, category, is_default, origin)
- GET /v1/api/ws-templates — enabled workstream template summaries (name, description, model)
- Available on both server and console; Python + TypeScript SDK methods added
- Console creation modal switched from admin endpoint to read-scope endpoint

Eliminate double-load inefficiency in workstream creation:
- Template validation moved before mgr.create() (no create-then-rollback on invalid template)
- template kwarg plumbed through WorkstreamManager.create() and session factory
- _SessionFactory Protocol added for proper mypy typing

Add workstream creation modal to server web UI:
- Name, model, template dropdown, ws_template/profile dropdown
- Instrument panel aesthetic: gradient top border, blur backdrop, amber accent
- Focus trap, Escape/Enter keyboard handling, loading state, error display
- WCAG AA contrast compliance, reduced-motion support

* fix: add list_ws_templates SDK methods + regenerate OpenAPI snapshots

Add list_ws_templates() to Python SDK (async + sync) and listWsTemplates()
to TypeScript SDK for the new GET /v1/api/ws-templates server endpoint.
Add WsTemplateSummary + ListWsTemplateSummaryResponse TypeScript types.
Regenerate openapi-server.json and openapi-console.json snapshots.

Addresses Copilot review feedback on PR #67.

* fix: skip template pre-validation when resuming a workstream

When resume_ws is set, the request's template field is irrelevant —
resume() restores the template from workstream_config. Pre-validating
a stale template name would incorrectly return 400 before the resume
even runs.

Addresses Copilot review feedback on PR #67.
2026-03-15 02:09:31 -07:00
Patrick Buckley e2a199c9c3 feat: mermaid diagram rendering with lazy loading and theme integration (#69)
* feat: mermaid diagram rendering with lazy loading and theme integration

Integrate mermaid.js 11.13.0 (self-hosted, MIT, ~2.9MB) for rendering
```mermaid code blocks as inline SVG diagrams. Covers flowcharts,
sequence, class, ER, state, gantt, pie, timeline, and mindmap.

- Lazy-loaded via dynamic script injection on first mermaid block
  detection (not eagerly loaded on every page view)
- 3-state loader (idle/loading/ready) with callback queue
- Serialized rendering to avoid mermaid internal state corruption
- Theme integration via getComputedStyle reading CSS design tokens;
  re-renders all diagrams on dark/light theme toggle
- Source preserved in data-mermaid-source for theme re-rendering
- Error handling with source code fallback display
- securityLevel: "strict" (DOMPurify) for SVG XSS prevention
- THIRD-PARTY-NOTICES updated with mermaid MIT license

* fix: mermaid render fixes from Copilot review

- Call result.bindFunctions(container) after SVG insertion for
  interactive diagram elements (click handlers, links, tooltips)
- Clear mermaid-error class on successful render (fixes stale error
  styling after theme toggle re-render)
- Clear mermaid-error in reRenderAllMermaid before re-render sequence
- Restructure postRenderMarkdown so mermaid rendering runs even when
  highlight.js is unavailable (hljs guard changed from early return
  to conditional block)
2026-03-15 02:09:10 -07:00
Patrick Buckley 4152ea2352 fix: widen code fence regex and skip auto-detect on unlabeled blocks
- Regex changed from (\w*) to ([^\s`]*) to capture language names with
  special chars (c++, c#, objective-c, shell-session)
- Alias map normalizes c++ → cpp, c# → csharp, f# → fsharp for CSS
  class names
- Empty language no longer emits class="language-", preventing
  highlight.js auto-detect across all 37 bundled languages on
  unlabeled code blocks (performance fix for large blocks)
2026-03-15 02:04:58 -07:00
Patrick Buckley 44cc14b46f feat: syntax highlighting via highlight.js with code block variants (#68)
Integrate highlight.js 11.11.1 (self-hosted, BSD-3-Clause, ~125KB) for
language-aware syntax highlighting on fenced code blocks.

- postRenderMarkdown() hook applies highlighting at stream_end and
  history load — not during streaming (innerHTML replaced per token)
- Custom theme using CSS design tokens (auto-adapts dark/light)
- Code block variants: diff (green/red line coloring), bash/shell
  (terminal left-border), ascii/text/plaintext (no highlighting)
- Class prefix changed from lang- to language- (CommonMark standard)
- Graceful degradation when highlight.js unavailable
- THIRD-PARTY-NOTICES file for bundled dependency attribution
- pyproject.toml package-data glob for vendored hljs directory
2026-03-15 01:36:57 -07:00
Patrick Buckley 83b0cde32f feat: GFM extended syntax renderers (callouts, footnotes, definition … (#66)
* feat: GFM extended syntax renderers (callouts, footnotes, definition lists)

Add three GFM extended syntax features to the server web UI markdown
renderer, with no external library dependencies (pure JS/CSS):

- Callouts/Alerts: > [!NOTE], [!TIP], [!IMPORTANT], [!WARNING], [!CAUTION]
  with color-coded left borders, icons, and recursive markdown body
- Definition Lists: Term + `: Definition` pattern with multi-term support
- Footnotes: [^id] inline superscript references, [^id]: definitions
  collected into a numbered section with bidirectional navigation

Design review fixes: scoped footnote IDs (prevent collisions across
messages), aria-hidden on callout icons, aria-label on callout containers,
focus-visible on footnote links, smooth-scroll footnote navigation.

* fix: use getElementById for footnote scroll to handle special chars in IDs

querySelector throws on fragment IDs containing &, . or : characters
(produced by escapeHtml on footnote labels). getElementById accepts any
string and is the correct API for ID-based element lookup.
2026-03-15 01:33:54 -07:00
Patrick Buckley 2ef8a8711b feat: rich markdown renderer with LaTeX support for server web UI (#65)
* feat: rich markdown renderer with LaTeX support for server web UI

Extract markdown rendering from app.js into dedicated renderer.js with
full GFM support: tables (alignment, hover, striping), nested lists,
task list checkboxes, nested blockquotes, images (click-to-load for
privacy), and inline/display LaTeX math via self-hosted KaTeX 0.16.38.

Security: escape image/link URLs to prevent attribute injection, block
javascript: scheme in links, add rel="noopener noreferrer", images
require explicit click to load (no automatic external requests).

Accessibility: scope="col" on table headers, tabindex on scrollable
table containers, aria-labels on task checkboxes and image placeholders,
KaTeX error color override for WCAG AA contrast, reduced-motion support.

* fix: address code review — XSS hardening and list type splitting

- Escape all text through escapeHtml() at start of inlineMarkdown()
  so only renderer-generated tags appear in innerHTML (prevents raw
  HTML/script injection from LLM output)
- Replace inline onclick handler on image placeholders with data-*
  attributes and delegated DOM event listeners (prevents entity
  decoding XSS in event handler attributes)
- Split list blocks into separate <ul>/<ol> when marker type changes
  at the same indent level (mixed ordered/unordered sequences)
2026-03-15 00:04:01 -07:00
Patrick Buckley 3658b77de8 feat: Discord content catch-up + bidirectional notification replies (… (#64)
* feat: Discord content catch-up + bidirectional notification replies (#64)

Two improvements to the Discord channel adapter:

1. Fix intermittent dropped responses caused by a race between the
   bridge's two independent SSE connections (global SSE detects idle
   before per-ws SSE delivers all content tokens). The bridge now
   accumulates content in _ws_content_buffer and attaches it to
   TurnCompleteEvent.content. The Discord bot uses this as a catch-up
   when streaming events were missed.

2. Bidirectional notification replies — when the notify tool sends a DM,
   the message is tracked with the originating ws_id. Users can reply to
   the DM and the reply is routed to the workstream. The response is
   forwarded back to the DM, with the response itself tracked for
   multi-turn conversations. Includes user identity verification,
   stale notification feedback, and FIFO-capped tracking (100 entries).

* fix: address Copilot review — re-insert on unlinked user, deque buffer

- Re-insert _notify_ws_map entry when resolve_user returns None so the
  user can retry after linking (same pattern as user-mismatch re-insert)
- Rename _MAX_CONTENT_BUFFER_BYTES → _MAX_CONTENT_BUFFER_CHARS (len()
  returns characters, not bytes)
- Use deque + running total for O(1) popleft instead of list.pop(0)
2026-03-14 23:58:27 -07:00
Patrick Buckley 83577739e0 chore: bump version to 0.6.2
- MCP admin tab: database-backed server management, hot-reload,
  reconcile, unified config view, paste-based import
- Catch-up migration for builtin-admin permissions (017)
- `[all]` optional dependency group (@Burhan-Q)
2026-03-14 17:25:58 -07:00
Burhan 71d13936fe add "all" optional dep (#61) 2026-03-14 17:05:49 -07:00
Patrick Buckley 0cd061196c fix: catch-up migration ensuring builtin-admin has all 20 permissions (#63)
Migrations 011-016 each appended a permission to the builtin-admin role
via conditional UPDATE, but on some deployments these never applied.
Migration 017 idempotently sets the complete permission string rather
than appending incrementally.

Must be merged after feat/admin-mcp-servers (migration 016).
2026-03-14 17:03:21 -07:00
Patrick Buckley 19abc0cc65 feat: admin MCP Servers tab — database-backed MCP server management w… (#62)
* feat: admin MCP Servers tab — database-backed MCP server management with live status

Add MCP Servers admin tab (14th tab, System group) for managing MCP server
definitions via the database instead of static JSON config files.

Storage: `mcp_servers` table (migration 016), 6 CRUD methods on both SQLite
and PostgreSQL backends, `MCP_SERVER_MUTABLE` field allowlist.

Config priority chain: DB rows (if any enabled) → CLI `--mcp-config` →
`mcp.config_path` setting → none. Nodes auto-load from DB on startup via
`load_mcp_config(storage=)`.

Hot-reload: `reconcile_sync(storage)` diffs running servers against DB —
adds missing, removes stale, reconnects changed. `_db_managed` set tracks
DB-sourced servers so config-file servers (MCP_CONFIG env) are never removed
by reconcile. Per-server `AsyncExitStack` for clean teardown.

Reload pattern: console writes to DB then signals nodes via
`POST /_internal/mcp-reload` (update by reference, no config payload).

Console admin API: 7 endpoints under `/v1/api/admin/mcp-servers` (CRUD +
reload + import), `admin.mcp` permission, secret masking (env/headers
replaced with *** unless ?reveal=true), audit log sanitization.

Unified view: tab merges DB-managed servers with config-sourced servers
detected on nodes. Config servers shown as read-only rows with "config"
badge — no edit/delete.

Admin UI: 7-column grid with magenta status dots, transport badges,
single-column create/edit modal, paste-based JSON import (mcpServers format),
detail modal with per-node status. Mobile 3-column collapse, reduced-motion
support, backdrop-click dismiss, focus trapping.

SDKs: 7 methods on Python (async+sync) and TypeScript SDKs.

Also fixes: Settings tab permission gate (admin.users → admin.settings),
_ALL_PERMISSIONS list in governance.js (5 missing permissions added),
_internal/mcp-reload added to APPROVE_PATHS.

Docs: architecture.md (14 tabs), api-reference.md (7 endpoints),
20-mcp-architecture.puml updated with admin-driven lifecycle.

66 new tests (2232 total).

* fix: address Copilot review feedback on MCP admin PR

- Docs: fix "merges both sources" → "first-match-wins priority" (architecture.md)
- Validation: require command for stdio, url for streamable-http transport
- Validation: check args/headers/env types in import handler before storing
- Schema: add transport/command/url to McpServerStatus, source to McpServerDetail
- Thread safety: move all remove_server_sync mutations onto MCP event loop thread
- Regenerate OpenAPI JSON snapshots for TypeScript SDK
2026-03-14 17:02:50 -07:00
Patrick Buckley c5cdfc8f44 chore: bump version to 0.6.1 2026-03-14 13:19:40 -07:00
Patrick Buckley 8895bf07eb feat: admin Settings tab — form-based editor replacing "coming soon" … (#60)
* feat: admin Settings tab — form-based editor replacing "coming soon" stub

Section-grouped layout with collapsible headers for all ~40 ConfigStore
settings (model, session, tools, server, mcp, ratelimit, health, judge,
memory). Type-appropriate inputs: CSS toggle for bools, number with
min/max/step, select for choices, text for strings. Secret fields shown
read-only. Source badge (storage/default), amber restart indicator.

Inline save per field with dirty detection, row flash on success, reset
to default via styled confirm modal. Full WCAG keyboard accessibility
(Enter/Space on section headers, aria-labels, focus-visible). Mobile
responsive single-column at <700px. Reduced-motion safe.

* fix: Settings tab polish — help tooltips, context_window auto-detect, UX fixes

Settings UI:
- Help tooltips: ? button on ~25 settings with plain-English explanations
  and optional reference links (arXiv, Fowler, MCP spec). Click to toggle
  popover, Escape to dismiss, aria-expanded for accessibility.
- Sections start collapsed for scannable overview.
- Restart badge: hidden by default, shows when dirty, persists after save
  with amber glow. Positioned left of source badge.
- Secret row alignment fixed (transparent border matches input box model).
- Docs link in toolbar → Swagger UI Settings section.
- Number inputs: spin buttons hidden (Firefox/WebKit), empty value guard,
  numeric dirty detection (0.1 vs 0.10 no longer false positive).
- Secret reset button enabled when source=storage (clear legacy overrides).
- Space key repeat guard on section headers.
- Sidebar: sticky + max-height:100vh, no longer stretches with content.

Backend:
- context_window default changed from 131072 to 0 (auto-detect). Fallback
  lowered from 131K to 32K (realistic for local models when detection fails).
  Session normalizes 0→32768 defensively.
- Settings registry: help + reference_url fields on SettingDef, richer
  descriptions for model/session/tools/judge/memory settings.
- Schema API includes help + reference_url.
- Bootstrap system prompt: added Runtime Settings section.

Docs: tab counts updated to 13 across README, architecture, console, governance.
2026-03-14 13:12:40 -07:00
Patrick Buckley 101afd84da feat: database-backed settings (ConfigStore) with admin API (#59)
* feat: database-backed settings (ConfigStore) with admin API

Replace config.toml for non-bootstrap settings on the server with a
database-backed ConfigStore.  ~40 settings across model, session,
tools, server, mcp, ratelimit, health, judge, and memory sections are
now managed via the admin Settings API.  CLI flags for these settings
removed from the server entry point (CLI standalone tool unchanged).

Storage: system_settings table (migration 015) with composite PK
(key, node_id) for per-node overrides.  ON CONFLICT upsert in both
SQLite and PostgreSQL.  admin.settings permission granted to
builtin-admin role.

Settings registry (settings_registry.py): code-defined catalog of
all known settings with types, defaults, validation, descriptions.
Registry defaults aligned with previous argparse defaults.

ConfigStore (config_store.py): thread-safe in-memory cache loaded
from storage on init.  Lock-free reads via dict snapshot swap.
reload() for hot-reload via internal endpoint.

Secret settings (judge.api_key) blocked from write via admin API
(403) — must be configured via config.toml or env vars.

warn_migrated_settings() logs warnings for config.toml keys that
overlap with ConfigStore-managed settings.

Console admin API: GET /v1/api/admin/settings (list with effective
values), GET .../schema (registry catalog), PUT .../{key} (update),
DELETE .../{key} (reset to default).  Audit trail on mutations.

MQ: ConfigChangeEvent for cross-node cache invalidation (emission
from console deferred to bridge integration).

Python + TypeScript SDK methods.  63 new tests.  Feature docs at
docs/settings.md, PlantUML diagram 24-settings-architecture.

* fix: address PR review — config-reload scope, registry defaults, doc alignment

- config-reload endpoint requires approve scope (was write)
- config-reload handler is sync def (avoids blocking event loop)
- reasoning_effort passes empty string through (removes `or "medium"`)
- ratelimit.requests_per_second changed to float (matches RateLimiter)
- session.retention_days allows 0 (disable pruning)
- ratelimit.trusted_proxies added to registry + wired in server
- admin_list_settings filters to global settings only (no node_id ambiguity)
- admin_update_setting validates "value" key presence (400 if missing)
- Console config change fans out reload to nodes directly (no MQ dep)
- Docs aligned with actual API response shapes and masking ("***")
2026-03-14 11:42:18 -07:00
Patrick Buckley efd98712e9 feat: [memory] admin panel Memories tab — browse, search, inspect, de… (#57)
* feat: [memory] admin panel Memories tab — browse, search, inspect, delete

Add 13th admin tab in the Observe group for cluster-wide memory
management.  List view with type/scope filter dropdowns and debounced
search input.  Detail modal shows full metadata grid and scrollable
content block.  Delete from both list row and detail modal with
confirmation and audit trail.

Permission-gated behind admin.memories.  Escape key, backdrop click,
and focus trap wired for the detail modal.  Mobile responsive: hides
description and updated columns below 700px.

* fix: memory detail modal — focus, delete safety, CSS shorthand order

Address Copilot review feedback: move focus to close button on modal
open for keyboard accessibility, disable delete button and clear stale
handler during loading/error states to prevent wrong-memory deletion,
and fix font shorthand/font-size ordering in toolbar filter styles.
2026-03-14 02:42:43 -07:00
Patrick Buckley 67f43a7ee0 feat: [memory] REST API endpoints + SDK methods + docs (#56)
* feat: [memory] REST API endpoints + SDK methods + docs

Server API (4 endpoints):
- GET /v1/api/memories — list with type/scope/scope_id/limit filters
- POST /v1/api/memories — save (upsert) with validation
- POST /v1/api/memories/search — search by query (read scope)
- DELETE /v1/api/memories/{name} — delete by name+scope

Console admin API (4 endpoints):
- GET /v1/api/admin/memories — list all memories
- GET /v1/api/admin/memories/search — search with ?q= param
- GET /v1/api/admin/memories/{memory_id} — get by ID
- DELETE /v1/api/admin/memories/{memory_id} — delete by ID with audit

Storage: add delete_structured_memory_by_id, add mem_type filter to
count_structured_memories. Auth: memory DELETE requires write scope,
admin.memories permission added to valid set + builtin-admin role.

Python SDK: list_memories, save_memory, search_memories, delete_memory
on both server (async+sync) and console (async+sync) clients.

TypeScript SDK: matching methods + types on both clients.

Pydantic schemas with Literal type/scope validation, OpenAPI endpoint
specs on both servers. 33 endpoint tests + 8 auth scope tests.

Docs: docs/memory.md feature guide, api-reference.md endpoint docs,
23-memory-architecture.puml diagram.

Also fixes stray `total: int` on CreateChannelUserRequest.

* fix: [memory] address PR review — cross-user scope, schema types, snapshots

Security: user-scoped memory endpoints now bind scope_id to the
authenticated user's identity.  Providing a mismatched scope_id
returns 403, preventing cross-user memory access on all 4 server
endpoints.

Schema: MemoryInfo response uses MemoryType/MemoryScope Literals.
SearchMemoriesRequest uses filter Literals (empty string allowed).
Limit query params declare schema_type="integer" for correct OpenAPI.

Regenerate sdk/typescript/openapi-{server,console}.json snapshots.
Update count_structured_memories docstring for mem_type param.
Fix fallback response to use normalized name after save.

6 new security tests for user-scope access control.
2026-03-14 02:28:47 -07:00
Patrick Buckley 2888e8ce0a feat: MCP cluster-ops example — reference MCP server + SDK implementa… (#55)
* feat: MCP cluster-ops example — reference MCP server + SDK implementation

Standalone MCP server under examples/mcp-cluster-ops/ that exposes
tools for executing commands across a Turnstone cluster via the MQ
client SDK. Serves as a reference implementation for both MCP server
patterns (FastMCP, lifespan, tool handlers) and TurnstoneClient usage.

4 tools: list_nodes, run_on_node, run_on_nodes, run_on_all_nodes.
Parallel dispatch via asyncio.gather, raw ToolResultEvent output
capture, UTF-8 safe truncation, input validation, concurrency caps.

35 tests, ruff clean, mypy --strict clean.

* fix: address review feedback on MCP cluster-ops example

- Remove REDIS_SSL support (RedisBroker doesn't accept ssl kwarg)
- Move max-nodes check from _dispatch_parallel into tool handlers
  for consistent error shape (always returns {"error": ...} object)
- Propagate KeyboardInterrupt/SystemExit from asyncio.gather instead
  of swallowing them as per-node failures
- Fix _truncate omitted bytes count to reflect actual bytes dropped
  after multi-byte boundary adjustment
- Apply strip/dedup to node IDs in run_on_all_nodes (matching
  run_on_nodes behavior)
- Add __name__ guard to __main__.py
- Fix misleading UTF-8 byte count comment in tests
2026-03-14 02:23:43 -07:00
Patrick Buckley d1a248b413 feat: [memory] config section — configurable relevance_k, fetch_limit… (#54)
* feat: [memory] config section — configurable relevance_k, fetch_limit, max_content, nudge_cooldown, nudges

MemoryConfig dataclass in memory_relevance.py, constructed from
config.toml [memory] section via argparse defaults. Replaces
hardcoded constants in session.py. Master nudges=false switch
disables all metacognitive prompting.

* fix: wire memory config into apply_config and correct error wording

Add "memory" to apply_config sections so [memory] config.toml values
actually propagate. Fix "byte limit" → "character limit" since
len(content) measures characters.
2026-03-14 00:51:27 -07:00
Patrick Buckley 723cad24bb feat: structured memory system — typed/scoped memories with BM25 rele… (#53)
* feat: structured memory system — typed/scoped memories with BM25 relevance and metacognitive prompting

Replace flat key-value memories table with structured_memories (migration 014).
Four memory types (user/project/feedback/reference), three scopes
(global/workstream/user). Consolidate remember/recall/forget into two tools:
memory (action-based: save/search/delete/list) and recall (conversation
history only).

BM25 relevance scoring (extracted to turnstone/core/bm25.py) selects top-5
memories for system message injection based on conversation context.
Metacognitive prompting injects ephemeral nudges after corrections, tool
denials, workstream resume, and completion signals.

Scope isolation enforced: system message injection and nudge counts filtered
to visible memories only (global + current workstream + authenticated user).
User scope requires authentication. Content capped at 32KB. ILIKE/LIKE
metacharacters escaped in both backends.

113 new tests (2053 total).

* fix: CI failure + copilot review feedback

- Fix time.monotonic() cooldown: use None sentinel instead of 0.0
  default (monotonic clock starts at boot, not epoch — fresh CI
  runners have uptime < 300s so cooldown check always triggered)
- Catch sa.exc.IntegrityError specifically in upsert instead of
  broad Exception (copilot review)
- Preserve existing description/type on upsert when caller doesn't
  explicitly set them (copilot review)
- Add last_accessed + access_count columns to schema/migration for
  future LRU/LFU eviction support
2026-03-13 21:21:09 -07:00
Patrick Buckley 73cacc8ad6 feat: admin panel — right-aligned sidebar navigation with two-column … (#52)
* feat: admin panel — right-aligned sidebar navigation with two-column modals

Replace the horizontal tab bar (11 tabs, overflowing on standard monitors)
with a grouped sidebar on the right side, matching the admin button's
position in the header for natural spatial flow.

Sidebar: 5 groups (Identity, Automation, Governance, Observe, System) with
12 nav items including new Settings stub. Always visible on desktop (180px),
off-canvas drawer on mobile (<700px) sliding from right with backdrop.

Admin button: toggle behavior (click again to return to overview), active
state with amber highlight + top accent line, aria-expanded management.

Breadcrumb: shows active tab ("Admin / Users", "Admin / Audit", etc).

Modals: WS Template and Schedule create/edit forms restructured into
two-column grid (820px) with "Identity"/"Model Config" and
"Schedule"/"Execution" column headings. All modals gain max-height: 85vh
+ overflow-y: auto safety net. Modal z-index bumped to 600 (above sidebar).

Also: "Tokens" renamed to "API Tokens", redundant "Server default"
placeholders removed from model config fields, view fade-in transition,
comprehensive ARIA (grouped sidebar, aria-hidden on mobile, focus return
on drawer close), reduced-motion support.

* fix: address Copilot review — aria-orientation, settings permission gate, inert sidebar

- Add aria-orientation="vertical" to sidebar tablist for assistive tech
- Gate Settings tab behind admin.users permission so empty-state logic
  works correctly when user has no admin permissions
- Use inert attribute on mobile sidebar when closed to prevent keyboard
  focus from reaching off-canvas controls
- Add resize listener to sync aria-hidden/inert when crossing the
  700px mobile breakpoint
2026-03-13 19:50:35 -07:00
Patrick Buckley ccd1c1a9ad chore: bump version to 0.6.0
Workstream templates (#49), intent validation (#50), conversation schema redesign (#51).
2026-03-13 14:43:01 -07:00
Patrick Buckley 1295919613 fix: simplify conversation storage — atomic assistant rows with tool_… (#51)
* fix: simplify conversation storage — atomic assistant rows with tool_calls JSON

Replace the denormalized storage model (separate rows for assistant
content, tool_call, tool_result) with atomic assistant rows carrying
tool_calls as a JSON column. Eliminates the 100-line heuristic
reconstruct_messages function and its cross-turn merge bug.

Schema: add tool_calls TEXT column to conversations (migration 013).
Migration backfills existing data — merges tool_call rows into their
parent assistant row as JSON, renames tool_result to tool, deletes
consumed tool_call rows.

Session save path: assistant content + tool_calls saved in one
save_message call before tool execution (crash resilient). Tool
results saved as role="tool".

Extract shared storage utilities to _utils.py: row_to_dict, mutable
field frozensets, reconstruct_messages. Both backends import from
_utils — PostgreSQL no longer depends on _sqlite.py.

Includes denied/blocked tool call badge fix on resume: _build_history
detects denied results and propagates flag to parent assistant entry.
Frontend uses flag for correct badge-denied rendering. Denied tools
visually muted. role="status" on badges for accessibility.

Net -45 lines. 8 new tests for reconstruction, all 1914 tests pass.

* fix: migration 013 uses parameterized deletes and ordered downgrade

- DELETE of consumed tool_call rows now uses parameterized batches
  (chunks of 500) instead of string interpolation
- Downgrade rebuilds via temp table to preserve chronological id
  ordering when re-inserting tool_call rows
2026-03-13 14:26:05 -07:00
Patrick Buckley 09ea3d164d feat: intent validation v1 — advisory LLM judge for tool approvals (#50) (#50)
* feat: intent validation v1 — advisory LLM judge for tool approvals (#50)

Two-tier evaluation pipeline for non-auto-approved tool calls:
- Heuristic tier (instant): 23 pattern-based rules across 4 severity
  levels (critical/high/medium/low) with first-match-wins priority
- LLM judge tier (async): multi-turn evaluation with read_file/
  list_directory tool access, security-hardened path blocking, forcing
  message on final turn, four-stage JSON parsing with retry nudge

Progressive UI: heuristic verdict badge + judge spinner, LLM verdict
upgrade via intent_verdict SSE event, glow on action buttons. Verdict
persisted to intent_verdicts table for audit. Prometheus metrics for
verdict counts and LLM latency. Enabled by default (--no-judge to opt
out). 132 new tests (1938 total).

Integration: session, server/WebUI, CLI, MQ bridge, console admin API,
Discord channel adapter. Config via [judge] in config.toml or CLI flags.

* fix: address PR #50 Copilot review feedback

- Fix double JSON encoding of func_args in both heuristic and LLM
  verdict persistence paths — use pre-serialized string from verdict
- Fix confidence 0.0 treated as falsy in channel verdict formatter
- Fix timestamp format inconsistency in storage backends (isoformat
  vs strftime) — now uses strftime consistently
- Add on_intent_verdict to eval.py NullUI (mypy fix)
- Fix late verdict after approval resolved — store last decision and
  apply immediately to late-arriving verdicts
- Add permission rollback to migration 012 downgrade
- Update docs to reflect judge enabled by default
- Document confidence_threshold as reserved for v2

* fix: judge per-call timeout and credential recon heuristic

- Wrap create_completion() in ThreadPoolExecutor with per-call timeout
  to prevent indefinite hangs on slow local models. On timeout, replace
  the executor so subsequent batch items don't queue behind lingering
  API calls
- Add IntentJudge.shutdown() and wire into session.close() for cleanup
- Add credential-recon heuristic rule: /etc/passwd, /etc/shadow,
  /etc/master.passwd access flagged as HIGH/review (reconnaissance
  pattern even though the command itself is read-only)
- 3 new tests for credential file access patterns

* fix: denied/blocked tool calls show correct badge on resume

- _build_history() detects denied results ("Denied by user") and
  blocked results ("Blocked") and propagates denied flag to parent
  assistant entry for frontend consumption
- Frontend history replay uses denied flag for badge-denied class
  instead of hardcoding badge-approved for all historical tool calls
- Denial feedback always prefixed with "Denied by user:" so content
  detection works with custom user feedback
- Denied tools visually muted (opacity 0.55, muted tool name)
- role="status" on all approval badge elements (accessibility)
- Broadened "Blocked" prefix match (catches "Blocked by tool policy")
2026-03-13 04:12:46 -07:00
Patrick Buckley 02d9c5c797 feat: workstream templates — behavioral profiles for workstream creation (#49)
* feat: workstream templates — behavioral profiles for workstream creation

Workstream templates define the complete configuration for workstream
creation: system prompt, model, auto-approve policy, per-tool
auto-approve, temperature, reasoning effort, max tokens, agent max
turns, token budget, and completion notifications. Applied once at
creation time (snapshot, not live binding). Auto-versioning captures
pre-update state on every edit.

Schema & storage:
- workstream_templates + workstream_template_versions tables (migration 011)
- ws_template_id/ws_template_version columns on workstreams table
- ws_template column on scheduled_tasks table
- Full CRUD + versioning on SQLite and PostgreSQL backends
- prompt_template_hash (SHA-256) for drift detection

Runtime:
- Template resolution before mgr.create() for model override
- Post-creation settings application (prompt, temperature, approval, budget)
- Token budget enforcement in session.send() — 80% warning, approval gate
  at 100% via __budget_override__ synthetic tool
- WebUI.auto_approve_tools server-side per-tool auto-approve
- Prompt template drift detection (hash comparison, log warning on mismatch)

Integration:
- ws_template field on CreateWorkstreamMessage, bridge, channel router,
  scheduler dispatch, MQ client
- Console admin "WS Templates" tab (11th) with CRUD, version history modal
- Profile dropdown on workstream creation modal
- WS template dropdown on scheduler create/edit modals
- Prompt template name validation on ws_template create/update
- 7 console admin API endpoints + read-only summary endpoint
- Full OpenAPI spec entries in console_spec.py
- Python SDK (sync + async) and TypeScript SDK methods
- Pydantic schemas for all request/response models

Docs & diagrams:
- New 21-ws-template-architecture.puml sequence diagram
- Updated governance, storage, MQ protocol diagrams + PNGs
- Updated architecture.md, governance.md, api-reference.md, console.md, sdk.md

48 new tests (1788 total). mypy clean. ruff clean.

* fix: address PR #49 review feedback

- auto_approve_tools uses approval_label (not just func_name) for
  consistency with tool policy evaluation
- inline system_prompt from ws_template persisted as
  _ws_template_system_prompt in workstream_config, restored on resume
  (previously lost because _template_content wasn't persisted)
- budget gate (__budget_override__) no longer bypassed by blanket
  auto_approve — requires explicit approval or tool policy allow
- diagram 21 field list corrected (removed tool_search/threshold,
  added prompt_template_hash/notify_on_complete)

* fix: address PR #49 review feedback (round 2)

- Grant admin.ws_templates permission in migration 011 (tab was hidden)
- Center WS template modals and fix radio button alignment
- Skip template validation when ws_template overrides prompt
- Guard against empty version snapshots on no-op updates
- Replace setTimeout race with Promise chain in schedule ws_template select
- Validate numeric fields in admin create/update handlers (400 not 500)
- Add ws_template to TypeScript OpenAPI specs
- Use typed Pydantic response models in SDK ws_template methods
2026-03-12 21:06:51 -07:00
Patrick Buckley f1f448277f chore: bump version to 0.5.6
Prompt template runtime wiring, security hardening, scheduler/channel/MQ
template support, migration 010.
2026-03-12 16:58:20 -07:00
Patrick Buckley 2f7f70825b feat: wire prompt templates into session startup with full creation-p… (#47)
* feat: wire prompt templates into session startup with full creation-path support

Prompt templates (prompt_templates table) now have runtime effect:

- is_default=true templates auto-apply as system message content,
  concatenated in name order before user instructions
- Per-workstream template selection via --template CLI flag, template
  field on POST /v1/api/workstreams/new, console creation modal dropdown,
  scheduled task config, and channel adapter config
- {{model}}, {{ws_id}}, {{node_id}} variable substitution via single-pass
  regex (prevents cross-variable injection)
- /template slash command for runtime switching, persisted across resume
- set_template() public API on ChatSession

Security hardening:
- MCP sync resets is_default=False on content update (prevents compromised
  server from injecting defaults)
- 32KB content cap on template create/update + defensive truncation
- Template existence validation returns 400 before workstream creation
- Single-pass regex eliminates cross-variable expansion

Template field plumbed through all creation paths: CLI, server API, MQ
protocol/bridge, console backend, scheduler dispatch, channel router,
MQ client. Migration 010 adds template column to scheduled_tasks.

Frontend: console workstream modal template dropdown, scheduler
create/edit template field, governance template UI variables auto-detected
from content (read-only display replaces editable input). Focus trap and
Enter-key accessibility fixes in workstream modal.

Docs: governance.md template runtime section, api-reference.md template
field, governance + MCP architecture diagrams updated.

Python + TypeScript SDKs, Pydantic schemas all updated. 29 new tests.

* fix: address PR #47 review feedback

- Defer template validation until after resume_ws — a bad template name
  no longer 400s when resume would have ignored it anyway
- Add template field to OpenAPI JSON specs (openapi-server.json,
  openapi-console.json) for SDK/docs consistency
- Validate template existence in schedule create and update endpoints —
  reject unknown template names with 400 instead of allowing schedules
  that would silently fail at dispatch time
2026-03-12 16:57:31 -07:00
Patrick Buckley 4866c9873c feat: ddgCluster compose profile with DuckDuckGo Search MCP sidecar (#48)
Add ddgCluster profile extending the 10-node cluster with a DuckDuckGo
Search MCP sidecar. All cluster nodes connect via streamable-http and
gain duckduckgo_web_search + duckduckgo_fetch_content tools. No API
key required.

Key implementation details learned during testing:
- MCP SDK DNS rebinding protection must be disabled for Docker
  internal networking (Host header uses container names)
- FastMCP server binds to 127.0.0.1 by default; must set
  mcp.settings.host='0.0.0.0' for cross-container access
- DDG CLI lacks --host/--port flags; settings configured via Python
  entry point that patches FastMCP.settings directly
- Safe search disabled by default

Also adds MCP_CONFIG env var support to all server commands (shell
conditional, no-op when empty) and moves default server/bridge to
production profile for cleaner profile separation.
2026-03-12 16:51:53 -07:00
Patrick Buckley 8b2e2130fc fix: MCP resource template URI expansion via prefix matching (#46)
* fix: MCP resource template URI expansion via prefix matching

Resource templates (RFC 6570 URI patterns like `db://tables/{table}/rows/{id}`)
were discovered from MCP servers but non-functional — `read_resource_sync()`
only accepted exact URIs from `_resource_map`, which excludes templates.

Add prefix-based fallback: extract the static prefix from each template
(everything before the first `{`), store a prefix→server mapping, and
fall back to longest-prefix matching when exact URI lookup fails. MCP
servers handle URI routing internally so we just need to route the
expanded URI to the correct server.

Also surface templates in the system message catalog and `/mcp` command
so the model knows they exist and can construct expanded URIs.

* fix: address PR #46 review feedback

- Template prefix collision now keeps more specific (longer) template
  URI instead of blindly overriding
- Fix _match_template docstring to accurately describe startswith
  matching on static prefixes (not full template matching)
- Add missing loop.close() in integration test finally block
- Rewrite test_template_longest_prefix_wins with genuinely different
  prefix lengths to avoid brittle collision-order dependency
2026-03-12 15:46:00 -07:00
Patrick Buckley f81c06761d chore: remove dead code, add MCP integration + collector tests (#45)
* chore: remove dead code, add MCP integration + collector tests

Remove unused delete_prompt_templates_by_server from protocol and
both storage backends (sync uses per-template deletion).

Add 10 MCP integration tests exercising full lifecycle: rebuild
resources/prompts, read_resource_sync/get_prompt_sync with real
asyncio loop, governance sync to real SQLite, shutdown cleanup,
listener notification isolation.

Add 3 console collector MCP aggregation tests: multi-node sums,
absent when zero, mixed nodes with/without MCP.

* fix: close event loops and SQLite backend in MCP integration tests
2026-03-12 15:16:51 -07:00
Patrick Buckley be165c1971 feat: MCP resource and prompt discovery with read_resource tool (#44)
* feat: MCP resource and prompt discovery with read_resource tool

Extends MCPClientManager with resource and prompt discovery alongside
existing tool support. Resources and prompts are discovered on connect,
cached per-server with copy-on-write rebuilds, and refreshed via push
notifications, periodic polling, or manual /mcp refresh.

New read_resource built-in tool reads MCP resources by URI. Requires
user approval (same as MCP tool calls) since resources are served by
external MCP servers. Resource catalog injected into system message
with XML delimiters. Error messages sanitized to prevent leaking
server internals to the model.

Prompt discovery stores prefixed names (mcp__server__prompt) and
exposes get_prompt_sync() for future use_prompt tool (Chunk D).

/mcp command now shows tools, resources, and prompts. Docs and
diagrams updated.

* feat: MCP prompt governance sync with origin tracking and readonly guards

Migration 009 adds origin, mcp_server, and readonly columns to
prompt_templates. MCP prompts discovered by MCPClientManager are
automatically synced into the governance table as read-only templates
with origin="mcp".

Sync engine handles: create on connect, update on prompt refresh,
delete when prompts are removed from server. Manual templates take
precedence on name collision (MCP prompt skipped with warning).

Admin API returns 403 on update/delete of readonly templates. Console
UI shows MCP origin badge and disables edit/delete buttons. Storage
backends gain get_prompt_template_by_name, list_prompt_templates_by_origin,
and delete_prompt_templates_by_server methods.

Also addresses PR #44 review feedback: concurrent.futures.TimeoutError
handling in sync dispatch, XML-escape resource catalog descriptions,
resource template entries excluded from _resource_map, URI collision
warnings, needs_periodic capability-aware computation, malformed JSON
primary key fallback for read_resource.

* feat: use_prompt tool, prompt catalog, and PR review hardening

New use_prompt built-in tool invokes MCP prompt templates by name,
expanding them into messages. Requires user approval (external MCP
servers). Prompt catalog injected into system message with XML
delimiters (up to 30 prompts, HTML-escaped).

Prompt listener registered in session for catalog rebuild on changes.

Addresses PR #44 review feedback:
- _init_system_messages() now uses copy-on-write (build locally,
  assign atomically) so background thread callbacks never see
  partial system messages
- sync_prompts_to_storage() serialized behind _sync_lock to prevent
  races between set_storage() (main thread) and MCP background thread
- shutdown() clears listener lists to release callback references

Docs and diagrams updated for 18 built-in tools.

* feat: granular tool policies for MCP resources, prompts, and tools

Policy evaluation now uses approval_label (falling back to func_name)
for fnmatch pattern matching, enabling fine-grained per-URI and
per-server policies:
- read_resource: mcp_resource__{normalized_uri}
- use_prompt: mcp__{server}__{prompt} (prefixed name)
- MCP tools: mcp__{server}__{tool} (was static "mcp_tool")

URI normalization resolves .. path segments to prevent traversal
bypasses in policy matching. Resource templates filtered from system
message catalog (not directly readable). use_prompt arguments
validated as dict with string coercion.

TypeScript SDK PromptTemplateInfo gains origin, mcp_server, readonly
fields. Governance docs updated with MCP policy patterns.

* feat: MCP visibility in server and console UIs

Server health endpoint includes mcp.servers, mcp.resources, mcp.prompts
counts. Server UI status bar shows magenta MCP indicator with tooltip.
Console cluster status bar shows MCP metrics with magenta LED dot.
Console node detail view shows per-node MCP summary. Console collector
aggregates MCP counts across nodes in overview.

Uses var(--magenta) design token with new --magenta-glow for theme
adaptation. ARIA roles on MCP status elements. Tooltips on console
MCP metric labels. Node MCP summary hidden on mobile (< 700px).

New diagram: 20-mcp-architecture.puml covering full MCP lifecycle
(connection, discovery, refresh, governance sync, policy, UI).

* fix: McpStatus in health schema, count properties, catalog name fidelity

Adds McpStatus model to HealthResponse (Python + TypeScript SDKs) so
typed clients see the mcp field from /health.

Addresses Copilot review feedback:
- resource_count/prompt_count properties avoid list allocation on
  /health and /metrics polls
- get_tools/resources/prompts return shallow-copied dicts to prevent
  callers from mutating internal cache
- Prompt names and arg names in system message catalog are NOT
  HTML-escaped (model must use exact strings in use_prompt calls);
  only descriptions are escaped

* fix: OpenAPI spec McpStatus + diagram approval column accuracy

Adds McpStatus schema and optional mcp field to HealthResponse in
openapi-server.json, matching the Python schema and TypeScript types.

Fixes tool pipeline diagram: math, web_fetch, web_search correctly
shown as auto-approve (not "Yes" for approval).
2026-03-12 14:49:58 -07:00
Patrick Buckley 3264fdefca fix: channel bidirectional routing — emit TurnCompleteEvent on all id… (#43)
* fix: channel bidirectional routing — emit TurnCompleteEvent on all idle transitions

Bridge previously only emitted TurnCompleteEvent for MQ-initiated turns
(those with a correlation_id in _active_sends). Server-UI-initiated turns
went idle without emitting TurnCompleteEvent, so the Discord bot's
StreamingMessage never finalized — content accumulated in the buffer and
collided with the next Discord-triggered response.

Now TurnCompleteEvent is emitted unconditionally on every idle transition.
correlation_id is empty for non-MQ turns; SDK client filters by
correlation_id so existing consumers are unaffected.

* fix: remove unused variable flagged by ruff
2026-03-12 11:57:15 -07:00
Patrick Buckley 28cb3a5c51 fix: approval timeout UI state and content flush before tool calls (#42)
* fix: approval timeout UI state and content flush before tool calls

Two bug fixes:

1. Approval timeout now shows denied state in UI — resolve_approval()
   emits an approval_resolved SSE event so the browser transitions
   from pending to denied (red border + badge). Also fixes the cancel-
   during-approval path. Frontend resolveInlineApproval() gains a
   skipPost parameter to avoid redundant POST when server-initiated.
   ApprovalResolvedEvent added to Python and TypeScript SDKs.

2. Content streaming flushes pending buffer before tool call deltas —
   _stream_response() held up to 13 trailing chars in the pending
   buffer (for <think> tag detection) when transitioning to tool calls.
   Now flushed eagerly when tool_call_deltas arrive, before clearing
   in_think so reasoning text is correctly categorized.

* fix: address Copilot review feedback on PR #42

Patch _execute_tools in stream flush test to prevent real bash execution,
simplify confusing nested comprehension, and update resolve_approval()
docstring to reflect cancel/timeout call paths.
2026-03-12 11:43:06 -07:00
Patrick Buckley 8b11e0a6f9 fix: bridge retries node_id fetch indefinitely with capped backoff 2026-03-12 11:12:03 -07:00
Patrick Buckley 648ba477e1 refactor: list_user_roles uses _row_to_dict instead of positional row mapping 2026-03-11 21:14:58 -07:00
Patrick Buckley 7960784786 fix: usage events now record per-request tool_calls delta, not cumulative total 2026-03-11 21:12:03 -07:00
Patrick Buckley e06554d1ec feat: add channel admin endpoints to console OpenAPI spec 2026-03-11 21:08:28 -07:00
Patrick Buckley 8eb8722346 Bump version to 0.5.5 2026-03-11 20:24:12 -07:00
Patrick Buckley a2e2ffacd8 feat: robust plan quality gate, iterative refinement, and amend UX (#41)
* feat: robust plan quality gate, iterative refinement, and amend UX

Plan agent output from weak models often produced garbage (11-char plans
that echo the prompt). Two fixes:

1. Quality validation (_validate_plan) checks length, section structure,
   echo detection, and refusal patterns. Fails trigger one automatic
   retry with a coaching message injected into the agent's existing
   conversation, preserving all prior exploration context.

2. Iterative feedback loop — user feedback at plan review re-runs the
   plan agent via _refine_plan() instead of appending text to the tool
   result. Up to 5 refinement rounds. The plan file path is always
   included in the tool result so the outer model knows where it lives.

UI improvements:
- Web: Reject button dynamically becomes "Amend" (amber) when feedback
  is typed. Key hint badges (Esc/Enter) on plan buttons. Main input
  disabled during review. Light-theme contrast fix via --on-color var.
- CLI: Prompt shows all three actions (approve/amend/reject).
- Bridge: Race condition fix — clear pending entry before HTTP POST so
  sequential plan reviews from the refinement loop aren't skipped.

15 new tests covering validation, retry, and refinement.

* fix: address PR 41 review feedback

- Escape key in plan dialog now mirrors the Amend button: if feedback is
  typed, Esc sends the feedback (amend); if empty, Esc rejects. Previously
  Esc always hard-coded "reject", discarding typed feedback.

- Coaching message for plan retry now says "should include at least two of"
  instead of "MUST include these", matching the actual validation rule
  (_MIN_PLAN_SECTIONS = 2).

* feat: render plan inline in chat after approval

After the plan review dialog closes, the plan content is now rendered
as a collapsible inline block in the chat stream — styled with a
status header (approved/rejected/amending), markdown-rendered body,
and feedback note when amending. Uses the same makeCollapsible pattern
as tool output blocks.

* fix: prevent plan approval hang when inline render fails

The authFetch call that unblocks the server must fire before the
cosmetic inline plan rendering. Previously _addInlinePlan ran first
and any JS error (e.g. from renderMarkdown) prevented the API call,
leaving the session thread blocked forever.

- Move authFetch before _addInlinePlan
- Wrap _addInlinePlan in try-catch
- Guard against empty content
- Only auto-collapse plans longer than 12 lines

* fix: address PR 41 review feedback (round 2)

- Max refinement rounds no longer implicitly approve: the loop now
  shows the final plan for explicit approve/reject before proceeding.
  Previously exhausting 5 rounds silently accepted the last revision.

- Plan inline block: correct aria-label from "Tool output" to
  "Plan content" when makeCollapsible is applied.

- XSS concern (not applicable): renderMarkdown is used for all
  assistant messages — plan content follows the same trust model.

- Test loop concern (acknowledged): refinement tests verify component
  logic; full _execute_tools integration would require extensive
  mocking for marginal coverage gain.

* feat: thinking spinner + inline plan hardening

* fix lint
2026-03-11 20:22:41 -07:00
Patrick Buckley c6ba8d59b0 feat: bootstrap wizard — LLM-guided interactive setup for deployments
Add `turnstone-bootstrap`, a new entry point that uses any LLM (OpenAI,
Anthropic, or local/vLLM) to conversationally walk users through
configuring a Turnstone deployment. Generates .env files, setup.sh
scripts, and optional docker-compose overrides.

- Fully interactive startup (zero CLI args) with provider/model selection
- Auto-detects available models on local OpenAI-compatible endpoints
- 7 tools: read_file, write_file, generate_secret, check_port,
  validate_api_key, check_docker, finish
- Path traversal protection on file read/write
- Duplicate write detection (skips identical content)
- Bounded retry loop (3 attempts) on LLM errors
- Anthropic message conversion with consecutive-role merging
2026-03-11 01:58:39 -07:00
Patrick Buckley 087f5b49f6 Bump version to 0.5.4 2026-03-10 20:47:40 -07:00
Patrick Buckley fd507c6a3c feat: generation cancellation — stop button, cancel API, cooperative … (#40)
* feat: generation cancellation — stop button, cancel API, cooperative cancel

Add cooperative cancellation via threading.Event on ChatSession. The cancel
signal is set from outside the worker thread (HTTP handler, MQ bridge, or
Escape key) and checked at defined checkpoints: per streaming chunk, before
tool execution, inside bash commands, and at each sub-agent turn.

Core: GenerationCancelled(BaseException) exception, cancel()/_check_cancelled()
methods, partial content preservation in _stream_response, clean rollback in
send() with idle state emission (no re-raise).

Server: POST /v1/api/cancel endpoint, CancelledEvent SSE emission, worker
thread safety net.

Frontend: Stop button (■ Stop) with send/stop swap via setBusy(), Escape key
shortcut, cancelled event handler. Accessible: aria-label, focus-visible
override, light theme contrast, non-color differentiation.

MQ: CancelMessage inbound type, bridge _handle_cancel routed handler.

SDK: cancel() on Python async+sync clients, CancelledEvent in Python+TypeScript
event registries, isCancelledEvent type guard.

OpenAPI: CancelRequest schema + endpoint spec.

Docs: API reference, architecture, SDK docs updated. Diagrams: conversation
turn, tool pipeline, MQ protocol, workstream states, SDK architecture.

* fix: address PR #40 review feedback

- setBusy() now resets stopBtn.disabled so stop button is re-enabled on
  next generation after a successful cancel
- Gate cancel side effects (resolve_approval, resolve_plan, cancelled SSE
  event) on worker_thread.is_alive() to avoid spurious events when idle
- Add /v1/api/cancel endpoint and CancelRequest schema to TypeScript
  openapi-server.json to keep it in sync with Python-generated spec
2026-03-10 20:43:52 -07:00
Patrick Buckley 562c3c8ab7 docs: add governance section and missing diagrams to README 2026-03-10 19:50:07 -07:00
Patrick Buckley 4773535bb8 docs: add governance architecture diagram PNG 2026-03-10 19:41:35 -07:00
Patrick Buckley 7492816ab2 feat: governance — RBAC, tool policies, prompt templates, usage track… (#39)
* feat: governance — RBAC, tool policies, prompt templates, usage tracking, audit logging

Add comprehensive governance layer for the admin console:

- RBAC with 15 granular permissions, 3 builtin roles (admin, operator, viewer),
  custom role CRUD, user-role assignment with privilege escalation prevention
- Tool policies with glob pattern matching, priority-ordered evaluation
  (allow/deny/ask), enforced before auto-approve in WebUI.approve_tools()
- Prompt templates with variable substitution, categories, default flag
- Usage tracking: per-LLM-request token/tool metrics, aggregated queries
  (group by day/model/user), automatic 90-day pruning via scheduler
- Audit logging: append-only event trail for all admin mutations,
  filterable/paginated queries, automatic 365-day pruning, X-Forwarded-For
  aware IP extraction
- require_permission() enforced on all 35+ admin endpoints (users, tokens,
  channels, schedules, watches, roles, orgs, policies, templates, usage, audit)
- Field allowlists on storage update methods prevent mass-assignment bugs
- Self-deletion guard on admin_delete_user, delete_user cascades user_roles
- _row_to_dict helper eliminates ~400 lines of fragile positional row mapping
- _audit_context helper deduplicates 18 instances of audit boilerplate
- Migration 008: 7 new tables, 3 builtin roles, org_id on users
- Console admin panel: 5 new tabs (Roles, Policies, Templates, Usage, Audit)
  with permission-gated visibility, 7 modal dialogs, full keyboard accessibility
- Python + TypeScript SDK methods for all governance endpoints
- 120+ new tests (1554 total)

* fix: address PR #39 review feedback

- Rebuild serialized items after policy evaluation so denied/allowed
  verdicts are reflected in tool_info/approve_request SSE payloads
- Make `since` query param optional in usage OpenAPI spec (handler
  already defaults to last 7 days)
- Add response_model=StatusResponse to DELETE role/policy/template
  and POST/DELETE role assignment endpoints in OpenAPI spec
- Add missing org_id/created/updated fields to UserRoleInfo schema
- Add missing created field to AuditEventInfo schema
- Show "no permissions" empty state instead of loading inaccessible
  tab when all admin tabs are permission-gated
- Fix "13 permissions" → "15 permissions" in architecture.md and
  security.md
- Fix import sorting in test_audit.py and test_tool_policy.py

* fix: address PR #39 round 2 review feedback

- Clear stale permissions from sessionStorage on config-token login
  (auth.js _storePermissions)
- Only trust X-Forwarded-For when behind a proxy that sets
  X-Forwarded-Proto (conditional on is_secure_request trust model)
- Thread user_id from auth into WebUI.on_status for usage events
- Add created field to TS AuditEventInfo type
- Return typed Pydantic models from all SDK governance methods instead
  of dict[str, Any] — both async and sync clients
- Validate group_by param against allowed enum in admin_usage handler
- Add deterministic secondary sort (event_id DESC) to
  list_audit_events in both SQLite and PostgreSQL backends
2026-03-10 19:32:37 -07:00
Patrick Buckley d6ba1d5e25 fix: mypy no-any-return in agent context overflow handler 2026-03-10 14:11:09 -07:00
Patrick Buckley 41d1b27d34 Bump version to 0.5.3 2026-03-10 13:46:50 -07:00
Patrick Buckley 8bc284c60e fix: agent context overflow — truncate tool output, catch context errors
Agent tool outputs are now truncated to 16k chars to prevent search
results (14M+ chars observed) from blowing past the model's context
limit. On context-exceeded API errors, the agent returns its last
content instead of crashing.
2026-03-10 13:43:01 -07:00
Patrick Buckley a322d6b1d1 fix: sub-agent context — clean plan, merged task, no Qwen template error
Plan agent: own identity only (no base system prompt needed).
Task agent: base system prompt merged with task identity into a single
system message (needs tool patterns for tool execution).
Neither agent receives conversation history.

Fixes Jinja template error on Qwen models that reject system messages
appearing after non-system messages.
2026-03-10 13:36:23 -07:00
Patrick Buckley 70d495aa5b fix: per-workstream SSE fan-out — multiple consumers no longer steal … (#38)
* fix: per-workstream SSE fan-out — multiple consumers no longer steal each other's tokens

After 4d665a5 removed the single-consumer SSE lock, the shared
_event_queue let concurrent consumers (browser, bridge, console proxy)
race on Queue.get(), each receiving ~1/N of content tokens and producing
garbled streaming text.

Replace the single queue with per-client fan-out: each SSE connection
registers its own bounded queue (maxsize=500) on WebUI._listeners, and
_enqueue() copies every event to all registered queues. On eviction or
close, a ws_closed sentinel is injected so SSE generators exit promptly.

* fix: address CI failures and Copilot review feedback

- Handle ws_closed sentinel in events_sse generator (break on close)
- Guarantee sentinel delivery by evicting one item when queue is full
- Clear listeners list after injecting sentinels on cleanup
- Fix test_slow_consumer to fill only slow queue directly
- Fix ruff SIM117 (nested with), unused import, mypy unused-ignore
2026-03-10 13:25:16 -07:00
Patrick Buckley de64535221 Feat/eval improvements (#37)
* ci: add GitHub Release creation on tag push

* refactor: rename plan tool to create_plan

Rename plan → create_plan to resolve cross-provider tool selection
failures. Models consistently treated "plan" as a reasoning concept
rather than a callable tool. The new name is an unambiguous verb+noun
action. Also rename the parameter from prompt → goal for clarity,
add web_search to the default system prompt tool patterns

* feat: eval harness improvements inspired by autoresearch patterns

Major enhancements to turnstone-eval:

- Per-test timeout (--test-timeout, default 300s) and suite timeout
  (--suite-timeout) prevent stuck runs from blocking the suite
- Fast-fail skips remaining runs after ceil(n/2) consecutive zeros
- Summary table with colored PASS/WEAK/FAIL and append-only TSV output
- Progress reporting with running pass rate, token count, and ETA
- Parallel test execution via ProcessPoolExecutor (--parallel N)
- Per-role model assignment: test/optimizer/observer can use different
  models and providers (--optimizer-model, --observer-model, etc.)
  with auto-detection from base URL
- Improved optimizer and observer system prompts with structured
  failure-mode diagnosis, keep/discard rules, and trend analysis
- Fixed token counting (prompt tokens use last-turn value, not sum)
- Added math-calculation and web-search-query test cases
- Fixed multi-file-edit test (both files now contain the target string)

* fix: address Copilot review feedback

- Revert prompt token counting to sum (reflects billed usage)
- Add tool_args to fast-fail skipped run dicts for schema consistency
- Align approval_label with func_name ("create_plan")
- Add timeout to future.result() in parallel path (test_timeout + 30s)
- Document thread-leak trade-off on serial timeout path
2026-03-10 13:06:39 -07:00
Patrick Buckley 187d004033 feat: watch tool — periodic command polling within workstreams (#36)
* feat: watch tool — periodic command polling within workstreams

Add a new `watch` tool that lets the model (or user) set up periodic
polling of a shell command. Results inject as synthetic user messages
that trigger LLM turns, enabling reactive workflows like PR monitoring,
CI/CD status tracking, and deployment health checks.

Key design:
- Single tool with create/list/cancel actions
- Python expression DSL for stop conditions (restricted eval)
- Server-owned WatchRunner daemon (DB-persisted, survives eviction + restart)
- Three dispatch paths: idle, busy, and evicted workstream restore
- REST API for console visibility (GET /v1/api/watches, POST cancel)
- Migration 007, 8 storage CRUD methods, 75 new tests (1383 total)

* fix: address Copilot review — condition errors, restore deadlock, docs

- Condition eval errors now deactivate the watch immediately instead
  of silently looping until max_polls
- Restored (evicted) workstreams set auto_approve=True to prevent
  approval deadlocks with no connected user
- Tool description clarifies first-poll baseline behavior for change
  detection mode
- Diagram updated: DELETE → POST /v1/api/watches/{id}/cancel
2026-03-10 08:18:28 -07:00
Patrick Buckley 7ea150fa71 Bump version to 0.5.2 2026-03-09 13:42:28 -07:00
Patrick Buckley 4d665a5f62 fix: SSE reconnect loop — remove _sse_generation single-consumer lock
The _sse_generation mechanism assumed one SSE consumer per workstream,
but the bridge also maintains an SSE connection to each workstream.
When a new client connected (browser, proxy, or test), it incremented
the generation counter, killing the bridge's connection. The bridge
reconnected, killing the new client's connection — creating a
mutual-kill cascade that closed every SSE connection after one ping
cycle (5s).

Fix: remove _sse_generation entirely. sse-starlette handles disconnect
detection via its own ASGI task. Also remove the redundant
request.is_disconnected() check which raced with sse-starlette's
disconnect listener in Starlette 0.52.

Root cause confirmed via raw socket test: the server was sending
a zero-length chunked terminator (0\r\n\r\n) at exactly 5s,
cleanly ending the HTTP response body.
2026-03-09 13:40:36 -07:00
Patrick Buckley 3bc3250869 fix: recovered workstreams invisible in console UI (#35)
* fix: recovered workstreams invisible in console UI

Bridge startup recovery (_recover_workstreams) re-registered workstream
ownership but never published WorkstreamCreatedEvent to the cluster
channel. The collector's poll loop would pick up the workstream in its
internal state, but _apply_poll never fanned out SSE events to connected
browsers. Combined, this made channel-resumed workstreams invisible in
the console while remaining accessible through the proxied node UI.

- Bridge: emit WorkstreamCreatedEvent for each recovered workstream
- Collector: diff poll results and fan out synthetic ws_created/ws_closed
  events for workstream additions and removals
- Skip workstreams with empty IDs in poll processing
- Add 4 tests for poll-diff fanout behavior
- Update console data-flow diagram and architecture docs

* fix: address PR review — filter empty ws IDs, stable event ordering

- Filter empty-string keys from old_ids to avoid phantom ws_closed
  events if a previous poll inserted a workstream under key "".
- Sort set diffs before iterating so ws_created/ws_closed fanout
  order is deterministic across poll cycles.
2026-03-09 13:39:47 -07:00
Patrick Buckley db937486cf Bump version to 0.5.1 2026-03-09 01:48:18 -07:00
Patrick Buckley 554257ac4d fix: SSE proxy Firefox reconnect — Connection: keep-alive header 2026-03-09 01:46:35 -07:00
Patrick Buckley 5f0004dc91 feat: add ClusterSnapshot for instant console UI state rebuild (#34)
* feat: add ClusterSnapshot for instant console UI state rebuild

The console web UI was SSE-driven with no initial state — reloads and
navigation caused blank/loading gaps while waiting for API re-fetches.

Server-side: GET /v1/api/cluster/snapshot returns the full cluster state
(all nodes with workstreams + overview aggregates) built under a single
lock. The SSE stream now emits this snapshot as the first event on
connect (snapshot taken before listener registration to avoid race).

Frontend: local clusterState object mirrors the snapshot, patched
incrementally by SSE events. View navigation renders from local state
with no API round-trips. Fixes popstate/pushState history corruption
on Back/Forward navigation (pre-existing bug). Stable node sorting
with node_id tie-breaker on both server and client.

SDK: snapshot() method on Python (sync + async) and TypeScript console
clients. ClusterSnapshotEvent in event registries.

* fix: address review feedback and SSE proxy reconnect bug

Copilot review fixes:
- Atomic snapshot+register: new get_snapshot_and_register() acquires
  both state and listener locks, eliminating the event gap between
  snapshot read and listener registration.
- Debounce patch renders: patchClusterState uses requestAnimationFrame
  to batch rapid SSE events into a single recompute+render cycle.
- Fix health type: dict[str, str] → dict[str, Any] on all three
  console schema models (ClusterNodeInfo, NodeDetailResponse,
  ClusterSnapshotNode) since /health payloads contain nested objects.
- TypeScript ClusterSnapshotEvent: use concrete ClusterSnapshotNode[]
  and ClusterOverviewResponse types instead of Record<string, unknown>.

SSE proxy reconnect fix:
- _proxy_sse raw_stream now emits `: proxy-ping` comments every 3s
  when no upstream data arrives, preventing the browser EventSource
  from dropping idle connections. The raw byte passthrough refactor
  (4d11078) removed the proxy's independent keepalive — this restores
  it without reverting to EventSourceResponse.
2026-03-09 01:13:35 -07:00
Patrick Buckley 6cc1b3a5bd feat: add vision/image support to read_file tool (#33)
* feat: add vision/image support to read_file tool

read_file now detects image files (PNG, JPEG, GIF, WebP, BMP, TIFF, ICO)
and returns base64-encoded content parts for vision-capable models.
Non-vision models receive a text description instead. A new
supports_vision flag on ModelCapabilities gates the feature, with
config.toml [models.*.capabilities] overrides for local models
(vLLM, llama.cpp, NIM).

* fix: address PR review feedback

- Discard _read_files on no-vision OSError path, include exception detail
- Discard _read_files on oversized image error (not a successful read)
- Validate capabilities type from config.toml (reject non-dict)
- Clarify tool description re: vision behavior and offset/limit scope
- Remove unused os import in tests, fix import sort order
- Handle list content (image tool results) in eval.py tool result loop
2026-03-08 23:43:42 -07:00
Patrick Buckley cc9afe94cd get title in collector for console 2026-03-08 22:32:52 -07:00
Patrick Buckley 136b75fdef Bump version to 0.5.0 2026-03-08 04:47:10 -07:00
Patrick Buckley 4d1107839b refactor: use raw streaming for SSE proxy to preserve event framing (#32)
* refactor: use raw streaming for SSE proxy to preserve event framing

- Replace httpx_sse aconnect_sse with raw httpx.stream for SSE proxy
- Stream bytes verbatim to preserve server-side ping comments and event framing
- Add StreamingResponse with proper headers (Cache-Control, X-Accel-Buffering)
- Update compose.yaml to add 'cluster' profile to the service

* Refactor SSE proxy to raw byte passthrough

- turnstone/console/server.py: Replace aconnect_sse + EventSourceResponse with
  httpx.stream() + StreamingResponse for raw byte passthrough. Server pings,
  events, and comments now flow through verbatim. Added per-request timeout
  override (read=None, pool=None) for long-lived SSE streams.

- tests/test_console.py: Add 3 new tests for SSE proxy:
  - Ping and event preservation
  - Upstream error status handling
  - Client disconnect handling

- docs/console.md: Update SSE Proxy section to reflect raw byte passthrough
  approach.
2026-03-08 04:46:34 -07:00
Patrick Buckley 7d66bc2159 Bump version to 0.4.6 2026-03-08 03:44:22 -07:00
Patrick Buckley 165cbb2d29 Bump version to 0.4.5 2026-03-08 03:29:44 -07:00
Patrick Buckley c79c47b940 Add MCP dynamic tool refresh with push notifications and periodic pol… (#31)
* Add MCP dynamic tool refresh with push notifications and periodic polling

MCP tool lists now stay up-to-date without restart via three mechanisms:
push notifications (ToolListChangedNotification) for servers that support
it, staggered periodic polling for servers that don't, and manual
/mcp refresh [server] command. MCPClientManager tracks tools per-server
with copy-on-write rebuild, notifies ChatSession listeners which rebuild
tool lists and ToolSearchManager (preserving expanded tools).

* Address Copilot review feedback on MCP refresh PR

- Fix /mcp refresh typo matching (startswith → exact token check)
- Validate --mcp-refresh-interval >= 0 at parse time via shared
  nonneg_float in config.py (deduplicated from cli.py + server.py)
- Clamp negative refresh_interval to 0 in MCPClientManager constructor
- Fix periodic refresh first poll timing (was initial_delay + interval,
  now initial_delay then immediate first poll)
- Clarify _on_mcp_tools_changed docstring re: O(n) BM25 build cost
2026-03-08 03:28:38 -07:00
Patrick Buckley 660c273e8e remove old demo.svg 2026-03-08 01:47:34 -08:00
Patrick Buckley c7586abd0a Add dynamic tool search with native defer_loading for Anthropic/OpenAI (#30)
* Add dynamic tool search with native defer_loading for Anthropic/OpenAI

When MCP tools push the total tool count past a configurable threshold
(default 20), tool definitions are deferred to reduce token overhead and
improve tool selection accuracy. Three-tier approach mirrors the existing
web search pattern:

- Anthropic (Claude 4.x): native defer_loading + server-side BM25 search
- OpenAI (GPT-5.4+): native defer_loading + hosted search
- vLLM/llama/NIM: client-side BM25 fallback via synthetic tool_search tool

New module turnstone/core/tool_search.py with BM25Index (pure-Python,
zero deps) and ToolSearchManager (session-scoped visibility, expansion,
server hint generation). Discovered tools persist for the session lifetime
so the model only searches once per capability needed.

Config: [tools] search/search_threshold/search_max_results
CLI: --tool-search {auto,on,off}, --tool-search-threshold, --tool-search-max-results
Agents (plan/task) exempt — their scoped tool sets are always small.

43 new tests (1253 total). All diagrams regenerated with PlantUML 1.2025.2.

* Fix Copilot review feedback on tool search

- Fix _MCP_PREFIX_RE to handle underscores in server names (non-greedy match)
- Use ordered dict for _expanded to preserve tool discovery order
- Avoid constructing ToolSearchManager when below threshold in auto mode
- Return empty string from _mcp_server_summary when no servers (not "none")
- Fix CLI help text to reference threshold generically, not hardcoded "20"
- Fix agent exemption docs to accurately describe scoped tool sets
- Fix README to not hardcode "30+" threshold number
2026-03-08 01:43:38 -08:00
Patrick Buckley 14d57176ce Bump version to 0.4.4 2026-03-07 15:43:56 -08:00
Patrick Buckley 96084ca5f3 Fix PostgreSQL migration race condition with advisory lock
Multiple containers starting simultaneously race on Alembic migrations
against shared PostgreSQL. Use pg_advisory_lock so they wait in line.
Also update SQLite bootstrap to detect post-migration databases.
2026-03-07 15:41:29 -08:00
604 changed files with 211408 additions and 18357 deletions
+36 -16
View File
@@ -1,29 +1,49 @@
# =============================================================================
# Turnstone Environment Variables
# Copy to .env and adjust values for your deployment
# Copy to .env and adjust values for your deployment.
#
# Usage:
# Single node: docker compose --profile production up
# 10-node cluster: docker compose --profile cluster up
# =============================================================================
# -- LLM Backend --------------------------------------------------------------
LLM_BASE_URL=http://host.docker.internal:8000/v1
OPENAI_API_KEY=sk-...
# ANTHROPIC_API_KEY=sk-ant-... # Set instead for Anthropic provider
# TAVILY_API_KEY=tvly-... # For web search fallback (local models only)
OPENAI_API_KEY=dummy
# ANTHROPIC_API_KEY=sk-ant-...# Set instead of OPENAI_API_KEY for Anthropic
# TAVILY_API_KEY=tvly-... # Web search fallback (local models only)
# MODEL=# Override default model alias
# -- Database (production profile) --------------------------------------------
# DB_BACKEND=postgresql
# -- Authentication (required) ------------------------------------------------
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
TURNSTONE_JWT_SECRET=changeme-to-32-bytes-of-hex
# -- Database ------------------------------------------------------------------
# Single-node default is SQLite (zero config). Set these for PostgreSQL:
# TURNSTONE_DB_BACKEND=postgresql
# POSTGRES_USER=turnstone
# POSTGRES_PASSWORD=changeme
# DATABASE_URL=postgresql+psycopg://turnstone:changeme@postgres:5432/turnstone
# -- Redis ---------------------------------------------------------------------
# REDIS_PASSWORD=
# REDIS_PORT=6379
# -- Authentication ------------------------------------------------------------
# TURNSTONE_AUTH_ENABLED=true
# TURNSTONE_AUTH_TOKEN=your-secret-token
# TURNSTONE_JWT_SECRET=python -c "import secrets; print(secrets.token_hex(32))"
# TURNSTONE_DB_URL=postgresql+psycopg://turnstone:changeme@postgres:5432/turnstone
# -- Ports ---------------------------------------------------------------------
# SERVER_PORT=8080
# CONSOLE_PORT=8090
# -- Workspace -----------------------------------------------------------------
# Bind-mount a host directory into the container at /workspace.
# The model can read/write files here. Default: empty Docker volume.
# WORKSPACE_MOUNT=/path/to/your/project
# -- Agent behavior ------------------------------------------------------------
# SKIP_PERMISSIONS=true # Auto-approve all tool calls (dev only)
# MCP_CONFIG=/workspace/mcp.json# MCP server configuration file
# -- Discord channel gateway ---------------------------------------------------
# TURNSTONE_DISCORD_TOKEN=
# TURNSTONE_DISCORD_GUILD=0
# -- Cluster (profile: cluster) -----------------------------------------------
# These are set per-node in compose.yaml; only override for custom topologies.
# TURNSTONE_NODE_ID=node-1
# TURNSTONE_ADVERTISE_URL=http://server-1:8080
+142
View File
@@ -0,0 +1,142 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended",
"helpers:pinGitHubActionDigests",
":separateMajorReleases"
],
"gitIgnoredAuthors": [
"41898282+github-actions[bot]@users.noreply.github.com"
],
"labels": ["dependencies"],
"prConcurrentLimit": 5,
"prHourlyLimit": 2,
"schedule": ["before 9am on Monday"],
"timezone": "America/New_York",
"lockFileMaintenance": {
"enabled": true,
"schedule": ["before 9am on Monday"]
},
"customManagers": [
{
"customType": "regex",
"description": "Track vendored KaTeX version",
"managerFilePatterns": ["/pyproject\\.toml$/"],
"matchStrings": ["katex-(?<currentValue>[\\d.]+)/"],
"depNameTemplate": "katex",
"datasourceTemplate": "npm"
},
{
"customType": "regex",
"description": "Track vendored Highlight.js version",
"managerFilePatterns": ["/pyproject\\.toml$/"],
"matchStrings": ["hljs-(?<currentValue>[\\d.]+)/"],
"depNameTemplate": "highlight.js",
"datasourceTemplate": "npm"
},
{
"customType": "regex",
"description": "Track vendored Mermaid version",
"managerFilePatterns": ["/pyproject\\.toml$/"],
"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": [
{
"description": "LLM SDKs — always review manually",
"groupName": "LLM SDKs",
"matchPackageNames": ["openai", "anthropic", "mcp"],
"schedule": ["before 9am on Monday"],
"automerge": false
},
{
"description": "Web framework stack",
"groupName": "Web Framework",
"matchPackageNames": [
"starlette",
"uvicorn",
"sse-starlette",
"httpx",
"httpx-sse",
"pydantic"
],
"schedule": ["before 9am on Wednesday"],
"automerge": true,
"matchUpdateTypes": ["patch"]
},
{
"description": "Database layer",
"groupName": "Database",
"matchPackageNames": ["sqlalchemy", "alembic", "psycopg"],
"schedule": ["before 9am on Wednesday"],
"automerge": true,
"matchUpdateTypes": ["patch"]
},
{
"description": "Security-critical — always review manually",
"groupName": "Security",
"matchPackageNames": ["PyJWT", "pyjwt", "bcrypt"],
"automerge": false
},
{
"description": "Infrastructure dependencies",
"groupName": "Infrastructure",
"matchPackageNames": ["structlog", "croniter", "discord.py"],
"schedule": ["before 9am on the first day of the month"],
"automerge": true,
"matchUpdateTypes": ["patch"]
},
{
"description": "Vendored JS — CI workflow downloads files automatically",
"groupName": "Vendored JS",
"matchPackageNames": ["katex", "highlight.js", "mermaid", "hls.js"],
"schedule": ["before 9am on the first day of the month"],
"automerge": false
},
{
"description": "Dev/test tooling",
"groupName": "Tooling",
"matchPackageNames": [
"ruff",
"mypy",
"pytest",
"pytest-cov",
"pre-commit"
],
"schedule": ["before 9am on the first day of the month"],
"automerge": true,
"matchUpdateTypes": ["patch"]
},
{
"description": "Docker base images",
"groupName": "Docker Images",
"matchManagers": ["dockerfile", "docker-compose"],
"schedule": ["before 9am on the first day of the month"],
"automerge": false
},
{
"description": "TypeScript SDK dev dependencies",
"groupName": "TypeScript SDK",
"matchFileNames": ["sdk/typescript/**"],
"schedule": ["before 9am on the first day of the month"],
"automerge": true,
"matchUpdateTypes": ["patch"]
},
{
"description": "GitHub Actions — group all action updates",
"groupName": "GitHub Actions",
"matchManagers": ["github-actions"],
"automerge": false
}
]
}
+141 -17
View File
@@ -2,31 +2,35 @@ name: CI
on:
push:
branches: [main]
branches: [main, "stable/*"]
tags: ["v*"]
pull_request:
branches: [main]
branches: [main, "stable/*"]
permissions:
contents: read
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.13"
- run: pip install ruff
- run: ruff check turnstone/ tests/
- run: ruff format --check turnstone/ tests/
python-version: "3.14"
- run: pip install pre-commit
# mypy runs separately in typecheck job with full project deps
- run: SKIP=mypy pre-commit run --all-files
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.13"
- run: pip install mypy types-redis
- run: pip install -e ".[mq]"
python-version: "3.14"
- run: pip install mypy
- run: pip install -e ".[all]"
- run: mypy turnstone/
test:
@@ -35,14 +39,134 @@ jobs:
matrix:
python-version: ["3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: ${{ matrix.python-version }}
- run: pip install -e ".[test,mq]"
# 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@v4
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: always()
with:
name: coverage-${{ matrix.python-version }}
path: coverage.xml
test-postgres:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:18
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: turnstone_test
ports:
- 5432:5432
options: >-
--health-cmd="pg_isready -U postgres"
--health-interval=10s
--health-timeout=5s
--health-retries=5
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- 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:
TURNSTONE_TEST_PG_URL: postgresql+psycopg://postgres:postgres@localhost:5432/turnstone_test
wheel-completeness:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.14"
- run: pip install build
- run: python -m build --wheel
- name: Check all data files are in wheel
run: |
SOURCE=$(find turnstone -type f \
! -name '*.py' ! -name '*.pyc' ! -path '*__pycache__*' \
| sort)
WHEEL=$(python -m zipfile -l dist/*.whl \
| awk '{print $1}' \
| grep -v '\.py$' | grep -v '\.dist-info' | grep -v '\.pyc' | grep -v '^File$' \
| sort)
# Files intentionally excluded from the wheel (one per line)
ALLOW="
turnstone/core/storage/migrations/script.py.mako
"
MISSING=$(comm -23 <(echo "$SOURCE") <(echo "$WHEEL") \
| grep -vFxf <(echo "$ALLOW" | sed '/^[[:space:]]*$/d; s/^[[:space:]]*//' ) || true)
if [ -n "$MISSING" ]; then
echo "::error::Data files in source tree but missing from wheel:"
echo "$MISSING"
echo ""
echo "Add them to [tool.hatch.build.targets.wheel] in pyproject.toml"
echo "or to the ALLOW list in this job if intentionally excluded."
exit 1
fi
echo "All source data files present in wheel"
- name: Smoke-test entry points from installed wheel
run: |
python -m venv /tmp/smoke
/tmp/smoke/bin/pip install dist/*.whl
/tmp/smoke/bin/turnstone --help
/tmp/smoke/bin/turnstone-server --help
/tmp/smoke/bin/turnstone-console --help
/tmp/smoke/bin/turnstone-admin --help
/tmp/smoke/bin/turnstone-channel --help
/tmp/smoke/bin/turnstone-bootstrap --help
lock-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
uv-version: "0.9.18"
- run: uv lock --check
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
uv-version: "0.9.18"
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.14"
- run: uv sync --frozen --all-extras
- run: uv pip install pip-audit
- name: Security audit (dependencies)
run: uv export --no-emit-project --frozen | uv run pip-audit --strict --desc -r /dev/stdin
security-ts:
runs-on: ubuntu-latest
defaults:
run:
working-directory: sdk/typescript
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: "24"
- run: npm ci
- run: npm audit --audit-level=moderate
+81
View File
@@ -0,0 +1,81 @@
name: Publish Docker Image
on:
workflow_run:
workflows: ["CI"]
types: [completed]
concurrency:
group: docker-${{ github.event.workflow_run.head_sha }}
cancel-in-progress: true
permissions:
contents: read
packages: write
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
docker:
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
with:
ref: ${{ github.event.workflow_run.head_sha }}
fetch-depth: 0
- name: Resolve release tag
id: tag
run: |
TAG=$(git tag --points-at HEAD | grep '^v' | head -1)
if [ -z "$TAG" ]; then
echo "No v* tag at HEAD — skipping publish"
echo "skip=true" >> "$GITHUB_OUTPUT"
else
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- name: Log in to GHCR
if: steps.tag.outputs.skip == 'false'
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Compute Docker tags
if: steps.tag.outputs.skip == 'false'
id: tags
env:
REF: ${{ steps.tag.outputs.tag }}
run: |
VERSION="${REF#v}"
FULL="${REGISTRY}/${IMAGE_NAME}"
FULL="${FULL,,}"
if echo "$VERSION" | grep -qE '(a|b|rc)[0-9]+$'; then
TAGS="${FULL}:${VERSION},${FULL}:experimental"
else
MINOR="${VERSION%.*}"
TAGS="${FULL}:${VERSION},${FULL}:${MINOR},${FULL}:stable,${FULL}:latest"
fi
echo "tags=${TAGS}" >> "$GITHUB_OUTPUT"
- 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@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
with:
context: .
push: true
tags: ${{ steps.tags.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
+42 -6
View File
@@ -1,21 +1,57 @@
name: Publish to PyPI
on:
push:
tags: ["v*"]
workflow_run:
workflows: ["CI"]
types: [completed]
concurrency:
group: publish-${{ github.event.workflow_run.head_sha }}
cancel-in-progress: true
permissions:
contents: write
id-token: write
jobs:
publish:
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
environment: pypi
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
python-version: "3.13"
ref: ${{ github.event.workflow_run.head_sha }}
fetch-depth: 0
- name: Resolve release tag
id: tag
run: |
TAG=$(git tag --points-at HEAD | grep '^v' | head -1)
if [ -z "$TAG" ]; then
echo "No v* tag at HEAD — skipping publish"
echo "skip=true" >> "$GITHUB_OUTPUT"
else
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
if: steps.tag.outputs.skip == 'false'
with:
python-version: "3.14"
- run: pip install build
if: steps.tag.outputs.skip == 'false'
- run: python -m build
- uses: pypa/gh-action-pypi-publish@release/v1
if: steps.tag.outputs.skip == 'false'
- 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@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3
with:
tag_name: ${{ steps.tag.outputs.tag }}
generate_release_notes: true
draft: false
prerelease: ${{ contains(steps.tag.outputs.tag, 'a') || contains(steps.tag.outputs.tag, 'b') || contains(steps.tag.outputs.tag, 'rc') }}
+86
View File
@@ -0,0 +1,86 @@
name: Complete Vendored JS Updates
# When Renovate bumps a vendored JS version in pyproject.toml, this
# workflow downloads the actual files and commits them to the PR branch
# so the PR is merge-ready without manual intervention.
#
# Note: the commit is made with GITHUB_TOKEN, so it won't re-trigger CI
# automatically. The reviewer should re-run CI once this workflow passes,
# or Renovate's next rebase will trigger it.
on:
pull_request:
paths:
- pyproject.toml
workflow_dispatch:
inputs:
pr_number:
description: "PR number to update"
required: true
type: number
permissions:
contents: write
pull-requests: read
jobs:
vendor-js:
if: github.actor == 'renovate[bot]' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
steps:
- name: Resolve PR head ref
id: ref
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
ref=$(gh pr view "${{ inputs.pr_number }}" --repo "${{ github.repository }}" --json headRefName -q .headRefName)
else
ref="${{ github.head_ref }}"
fi
echo "head_ref=${ref}" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ steps.ref.outputs.head_ref }}
- name: Detect vendored JS changes
id: detect
run: |
updates=()
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
updates+=("${lib}:${version}")
done
if [[ ${#updates[@]} -eq 0 ]]; then
echo "found=false" >> "$GITHUB_OUTPUT"
else
echo "found=true" >> "$GITHUB_OUTPUT"
printf '%s\n' "${updates[@]}" > /tmp/updates.txt
echo "Libs to update:"
cat /tmp/updates.txt
fi
- name: Download vendored files
if: steps.detect.outputs.found == 'true'
run: |
while IFS=: read -r lib version; do
echo "::group::Updating ${lib} to ${version}"
bash scripts/update-vendored-js.sh "$lib" "$version"
echo "::endgroup::"
done < /tmp/updates.txt
- name: Commit and push
if: steps.detect.outputs.found == 'true'
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add -A
if git diff --cached --quiet; then
echo "No changes to commit"
exit 0
fi
git commit -m "chore: download vendored JS files"
git push
+4
View File
@@ -19,3 +19,7 @@ venv/
.hypothesis/
PROGRESS.md
.coverage
tools/skill_audit_analysis/data/
tools/skill_audit_analysis/output/
design_ideas/
.claude/
+3 -3
View File
@@ -1,16 +1,16 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.9.10
rev: v0.15.6
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.14.1
rev: v1.19.1
hooks:
- id: mypy
additional_dependencies: [types-redis>=4.6, redis>=7.2]
additional_dependencies: []
args: [--config-file=pyproject.toml]
pass_filenames: false
entry: mypy turnstone/
+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
+37 -26
View File
@@ -1,41 +1,49 @@
# =============================================================================
# Turnstone — multi-stage Docker build
# Single image for all services: server, bridge, console, sim, eval
# Turnstone — Docker build with uv for reproducible, locked installs
# Single image for all services: server, console, channel, eval
# =============================================================================
# ----------------------------------------------------------------------------
# Stage 1: Builder — build the wheel
# ----------------------------------------------------------------------------
FROM python:3.13-slim AS builder
WORKDIR /build
RUN pip install --no-cache-dir hatchling
COPY pyproject.toml README.md LICENSE ./
COPY turnstone/ turnstone/
RUN pip wheel --no-deps --wheel-dir /build/wheels .
# ----------------------------------------------------------------------------
# Stage 2: Runtime — slim image with the installed package
# ----------------------------------------------------------------------------
FROM python:3.13-slim
FROM python:3.14-slim
LABEL org.opencontainers.image.title="turnstone" \
org.opencontainers.image.description="Multi-node AI orchestration platform"
# System dependencies for psycopg (PostgreSQL client library)
RUN apt-get update && apt-get install -y --no-install-recommends libpq5 \
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
# System dependencies: psycopg (libpq5), developer tooling for agent workflows
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends \
libpq5 git curl jq man-db manpages procps file \
&& rm -rf /var/lib/apt/lists/*
# Node.js LTS (for npx-based MCP servers like @modelcontextprotocol/server-github)
COPY --from=node:24-slim /usr/local/bin/node /usr/local/bin/node
COPY --from=node:24-slim /usr/local/lib/node_modules /usr/local/lib/node_modules
RUN ln -s ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
&& ln -s ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx
# Non-root user
RUN useradd --create-home --shell /bin/bash turnstone
# Install the wheel with all optional extras
COPY --from=builder /build/wheels/*.whl /tmp/wheels/
RUN pip install --no-cache-dir "$(ls /tmp/wheels/*.whl)[mq,console,sim,postgres,discord]" \
&& rm -rf /tmp/wheels
WORKDIR /app
# Install dependencies first (cached layer — only re-runs when deps change)
COPY pyproject.toml uv.lock README.md LICENSE ./
RUN uv sync --frozen --no-install-project --no-dev \
--no-compile --extra all
# Install the project itself
COPY turnstone/ turnstone/
RUN uv sync --frozen --no-dev \
--no-compile --extra all
# Compile bytecode in a separate step (avoids fd exhaustion during install)
RUN python -m compileall -q .venv turnstone/
# Add venv to PATH so entry points are found
ENV PATH="/app/.venv/bin:$PATH"
# Health check script (stdlib only, no pip deps needed)
COPY docker/healthcheck.py /usr/local/bin/healthcheck.py
@@ -47,6 +55,9 @@ COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
WORKDIR /data
RUN chown turnstone:turnstone /data
# Workspace mount point — bind-mount a host directory here
RUN mkdir -p /workspace && chown turnstone:turnstone /workspace
USER turnstone
ENTRYPOINT ["entrypoint.sh"]
+92
View File
@@ -0,0 +1,92 @@
# Bootstrap Wizard
Interactive, AI-guided setup for Turnstone deployments. Instead of manually
editing `.env` files and reading deployment docs, the wizard walks you through
every decision conversationally and generates all the config files for you.
## Quick Start
```bash
turnstone-bootstrap
```
That's it — no flags, no arguments. The wizard prompts for everything.
## How It Works
1. **Pick a model** — Choose OpenAI, Anthropic, or a local/vLLM endpoint to
power the wizard. Local endpoints auto-detect available models.
2. **Answer questions** — The AI walks you through deployment mode, LLM
provider, database, authentication, ports, and optional features.
3. **Review generated files** — Each file is previewed before writing. You
confirm or reject every write.
4. **Start the stack** — The wizard prints the exact `docker compose` command
and a `setup.sh` script to create your first admin user, roles, and policies.
## What Gets Generated
| File | Purpose |
|------|---------|
| `.env` | All environment variables for `compose.yaml` |
| `setup.sh` | Post-start script: creates admin user, roles, tool policies, prompt templates via the API |
| `docker-compose.override.yaml` | Only if customizations beyond env vars are needed |
## Requirements
- **Python 3.11+** with turnstone installed (`pip install turnstone`)
- **An LLM API key** — for the wizard itself (OpenAI, Anthropic, or a local
model). This can differ from the LLM your deployment will use.
- **Docker & Docker Compose** — needed to run the stack. The wizard detects
whether Docker is installed and gives platform-specific install instructions
if it's missing. You can still generate config files without Docker.
## Deployment Modes
The wizard supports two deployment modes:
- **Single-node production** (`docker compose --profile production up`) —
1 server + console + PostgreSQL. Good for most use cases.
- **Multi-node cluster** (`docker compose --profile cluster up`) —
10-node server fleet + console + PostgreSQL. For high-throughput or
HA deployments.
## Example Session
```
$ turnstone-bootstrap
Turnstone Bootstrap Wizard v1.5.0
────────────────────────────────────────────────
Which provider for this wizard?
[1] OpenAI
[2] Anthropic
[3] OpenAI-compatible (local/vLLM)
> 3
Base URL [http://localhost:8000/v1]:
API key (press Enter for 'none'):
Querying http://localhost:8000/v1 for available models...
Found model: Qwen/Qwen3-32B
Connected to Qwen/Qwen3-32B. Handing off to AI assistant...
> (AI walks you through the rest interactively)
```
## Tips
- **Re-run safely** — running the wizard again detects your existing `.env`
and offers to update it rather than overwriting.
- **Duplicate writes are skipped** — if the LLM tries to write the same file
twice with identical content, it's silently ignored.
- **Type `quit` to exit** at any time during the conversation.
- **Ctrl+C** is handled gracefully — press once to interrupt, twice to exit.
## See Also
- [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
+93 -296
View File
@@ -5,348 +5,145 @@
[![Python](https://img.shields.io/pypi/pyversions/turnstone)](https://pypi.org/project/turnstone/)
[![License](https://img.shields.io/badge/license-BSL--1.1-blue)](LICENSE)
Multi-node AI orchestration platform. Deploy tool-using AI agents across a cluster of servers, driven by message queues or interactive interfaces.
Multi-node AI orchestration platform. Deploy tool-using AI agents across a cluster of servers with direct HTTP routing, interactive interfaces, and enterprise governance.
Named after the [Ruddy Turnstone](https://en.wikipedia.org/wiki/Ruddy_turnstone) — a bird that flips rocks to expose what's hiding underneath.
<p align="center">
<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.
### Release Tracks
| Track | Install | Docker | Description |
|-------|---------|--------|-------------|
| **Stable** | `pip install turnstone` | `ghcr.io/turnstonelabs/turnstone:stable` | Production-grade. Bugfixes only. |
| **Experimental** | `pip install turnstone --pre` | `ghcr.io/turnstonelabs/turnstone:experimental` | New features. May have rough edges. |
See [docs/releasing.md](docs/releasing.md) for the full release process.
## What it does
Turnstone gives LLMs tools — shell, files, search, web, planning — and orchestrates multi-turn conversations where the model investigates, acts, and reports. It runs as:
Turnstone gives LLMs tools — shell, files, search, web, planning — and orchestrates multi-turn conversations where the model investigates, acts, and reports.
- **Interactive sessions** — terminal CLI or browser UI with parallel workstreams
- **Queue-driven agents** — trigger workstreams via message queue, stream progress, approve or auto-approve tool use
- **Multi-node clusters** — generic work load-balances across nodes, directed work routes to a specific server
- **Cluster dashboard** — real-time view of all nodes and workstreams, workstream creation with node targeting, reverse proxy for server UIs (only the console port needs network access)
- **Cluster simulator** — test the stack at scale (up to 1000 nodes) without an LLM backend
- **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), Anthropic Messages API, and Google Gemini
- **MCP support** — external tool servers with native deferred loading (Anthropic/OpenAI) or BM25 fallback
<p align="center">
<img src="docs/diagrams/architecture-overview.svg" alt="Turnstone system architecture — data flow from clients through gateways, Redis MQ, cluster nodes, to LLM providers" width="960"/>
<img src="docs/diagrams/architecture-overview.svg" alt="Turnstone system architecture" width="960"/>
</p>
## Quickstart
### Interactive (terminal)
```bash
pip install turnstone
# Terminal REPL
turnstone --base-url http://localhost:8000/v1
# Browser UI
turnstone-server --port 8080 --base-url http://localhost:8000/v1
# Cluster dashboard
pip install turnstone[console]
turnstone-console --port 8090
```
### Interactive (browser)
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
```
### Queue-driven (programmatic)
```bash
pip install turnstone[mq]
turnstone-bridge --server-url http://localhost:8080 --redis-host localhost
```
```python
from turnstone.mq import TurnstoneClient
with TurnstoneClient() as client:
# Generic — any available node picks it up
result = client.send_and_wait("Analyze the error logs", auto_approve=True)
print(result.content)
# Directed — must run on a specific server
result = client.send_and_wait(
"Check disk I/O on this server",
target_node="server-12",
auto_approve=True,
)
```
### Cluster dashboard
```bash
pip install turnstone[console]
turnstone-console --redis-host localhost --port 8090
```
Then open `http://localhost:8090` for the cluster-wide dashboard. Create workstreams from the console and interact with any node's server UI through the built-in reverse proxy — no direct server port access required.
### Docker
```bash
cp .env.example .env # edit LLM_BASE_URL, OPENAI_API_KEY, etc.
docker compose up # starts redis + server + bridge + console (SQLite)
docker compose --profile production up
```
For production with PostgreSQL:
See [QUICKSTART.md](QUICKSTART.md) for the bootstrap wizard and [docs/docker.md](docs/docker.md) for Docker configuration and profiles.
```bash
# Requires POSTGRES_PASSWORD and DB_BACKEND=postgresql in .env (or exported)
docker compose --profile production up # adds PostgreSQL, uses it as database
### Programmatic (SDK)
```python
from turnstone.sdk import TurnstoneServer
with TurnstoneServer("http://localhost:8080", token="tok_xxx") as client:
ws = client.create_workstream(name="demo")
result = client.send_and_wait("Analyze the error logs", ws.ws_id, auto_approve=True)
print(result.content)
```
Console dashboard at http://localhost:8090. See [docs/docker.md](docs/docker.md) for configuration, scaling, and profiles.
### Simulator
Test the multi-node stack at scale without an LLM backend:
```bash
docker compose --profile sim up redis console sim
```
Or standalone:
```bash
pip install turnstone[sim]
turnstone-sim --nodes 100 --scenario steady --duration 60 --mps 10
```
See [docs/simulator.md](docs/simulator.md) for scenarios, CLI reference, and metrics.
All frontends connect to any OpenAI-compatible API (vLLM, NVIDIA NIM/NGC, llama.cpp, OpenAI, etc.) or Anthropic's native Messages API, and auto-detect the model.
## Architecture
### Diagrams
Detailed UML diagrams are available in [`docs/diagrams/`](docs/diagrams/):
| Diagram | Description |
|---------|-------------|
| [System Context](docs/diagrams/png/01-system-context.png) | Top-level components and external dependencies |
| [Package Structure](docs/diagrams/png/02-package-structure.png) | Python modules and dependency graph |
| [Core Engine Classes](docs/diagrams/png/03-core-engine-classes.png) | SessionUI protocol, ChatSession, LLMProvider, WorkstreamManager |
| [Conversation Turn](docs/diagrams/png/04-conversation-turn.png) | Full message lifecycle through the engine (provider-agnostic) |
| [Tool Pipeline](docs/diagrams/png/05-tool-pipeline.png) | Three-phase prepare/approve/execute |
| [MQ Protocol](docs/diagrams/png/06-mq-protocol.png) | 9 inbound + 19 outbound message types |
| [Message Routing](docs/diagrams/png/07-message-routing.png) | Multi-node routing scenarios |
| [Redis Key Schema](docs/diagrams/png/08-redis-key-schema.png) | All Redis keys, types, and TTLs |
| [Workstream States](docs/diagrams/png/09-workstream-states.png) | State machine transitions |
| [Simulator](docs/diagrams/png/10-simulator-architecture.png) | SimCluster, dispatchers, scenarios |
| [Console Data Flow](docs/diagrams/png/11-console-data-flow.png) | Dashboard data collection threads |
| [Deployment](docs/diagrams/png/12-deployment.png) | Docker Compose service topology |
| [SDK Architecture](docs/diagrams/png/13-sdk-architecture.png) | Python + TypeScript client libraries |
| [Storage Architecture](docs/diagrams/png/14-storage-architecture.png) | Pluggable database backends (SQLite + PostgreSQL) |
## Multi-node routing
Each Turnstone server runs a bridge process. Bridges share a Redis instance for coordination:
| Redis Key | Purpose |
|-----------|---------|
| `turnstone:inbound` | Shared work queue — generic tasks, any node |
| `turnstone:inbound:{node_id}` | Per-node queue — directed tasks |
| `turnstone:ws:{ws_id}` | Workstream ownership — auto-routes follow-ups |
| `turnstone:node:{node_id}` | Node heartbeat + metadata for discovery |
| `turnstone:events:{ws_id}` | Per-workstream event pub/sub |
| `turnstone:events:global` | Global event pub/sub |
| `turnstone:events:cluster` | Cluster-wide state changes (for turnstone-console) |
**Routing rules:**
1. Message has `target_node` → routes to that node's queue
2. Message has `ws_id` → looks up owner, routes to owning node
3. Neither → shared queue, next available bridge picks it up
Bridges BLPOP from their per-node queue (priority) then the shared queue. Directed work always takes precedence.
## Tools
14 built-in tools, 2 agent tools, plus external tools via MCP:
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.
| Tool | Description | Auto-approved |
|------|-------------|:---:|
| `bash` | Execute shell commands | |
| `read_file` | Read file contents | yes |
| `write_file` | Write/create files | |
| `edit_file` | Fuzzy-match file editing | |
| `search` | Search files by name/content | yes |
| `math` | Sandboxed Python evaluation | |
| `man` | Read man pages | yes |
| `web_fetch` | Fetch URL content | |
| `web_search` | Web search (provider-native or Tavily) | |
| `remember` | Save persistent facts | yes |
| `recall` | Search memories and history | yes |
| `forget` | Remove a memory | yes |
| `task` | Spawn autonomous sub-agent | |
| `plan` | Explore codebase, write .plan.md | |
| `mcp__*` | External tools from MCP servers | |
## Architecture
### MCP Tool Servers
**Single-node**: Client → Server (direct HTTP + SSE). No external dependencies beyond the database.
Turnstone supports the [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) for connecting external tool servers. MCP tools are discovered at startup, converted to OpenAI function-calling format, and merged with built-in tools. Each MCP tool is prefixed with `mcp__{server}__{tool}` to avoid name collisions.
**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.
Configure via `config.toml` or `--mcp-config`:
| 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 and Slack adapters) |
| `turnstone-admin` | User/token management CLI |
| `turnstone-eval` | Eval harness for prompt/tool optimization |
| `turnstone-bootstrap` | LLM-guided setup wizard |
```toml
[mcp.servers.github]
command = "npx"
args = ["-y", "@modelcontextprotocol/server-github"]
### Diagrams
[mcp.servers.github.env]
GITHUB_TOKEN = "ghp_..."
```
UML diagrams in [`docs/diagrams/`](docs/diagrams/):
Or use a standard MCP JSON config file:
| Diagram | Description |
|---------|-------------|
| [System Context](docs/diagrams/png/01-system-context.png) | Components and external dependencies |
| [Package Structure](docs/diagrams/png/02-package-structure.png) | Python modules and dependency graph |
| [Core Engine](docs/diagrams/png/03-core-engine-classes.png) | SessionUI, ChatSession, LLMProvider |
| [Conversation Turn](docs/diagrams/png/04-conversation-turn.png) | Message lifecycle through the engine |
| [Tool Pipeline](docs/diagrams/png/05-tool-pipeline.png) | Prepare / approve / execute |
| [Workstream States](docs/diagrams/png/09-workstream-states.png) | State machine transitions |
| [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 / 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 |
```bash
turnstone --mcp-config ~/.config/turnstone/mcp.json
turnstone-server --mcp-config ~/.config/turnstone/mcp.json
```
## Documentation
Use `/mcp` in the REPL to list connected tools. MCP tools require user approval by default (overridden by `--skip-permissions` or UI auto-approve).
### Multi-Model and Multi-Provider Support
Turnstone supports multiple model backends per server instance, including different LLM providers. `ChatSession` delegates all API communication to pluggable `LLMProvider` adapters — the internal message format stays OpenAI-like, and each provider translates at the API boundary. Define named models in `config.toml` and select per-workstream or switch mid-session with `/model <alias>`.
```toml
[models.local]
base_url = "http://localhost:8000/v1"
model = "qwen3-32b"
# provider defaults to "openai" (works with vLLM, llama.cpp, etc.)
[models.claude]
provider = "anthropic"
api_key = "sk-ant-..."
model = "claude-opus-4-6"
context_window = 200000
[models.openai]
base_url = "https://api.openai.com/v1"
api_key = "sk-..."
model = "gpt-5"
context_window = 400000
[model]
default = "local" # which model to use by default
fallback = ["claude", "openai"] # try these if the primary is unreachable
agent_model = "claude" # optional: separate model for plan/task sub-agents
```
Supported providers: `"openai"` (default -- OpenAI, vLLM, llama.cpp, any OpenAI-compatible API) and `"anthropic"` (Anthropic Messages API, requires `pip install turnstone[anthropic]`).
Use `/model` to show available models, `/model claude` to switch. Workstreams created via the API accept an optional `model` parameter.
## Configuration
All entry points read `~/.config/turnstone/config.toml`. CLI flags override config values.
```toml
[api]
base_url = "http://localhost:8000/v1"
api_key = ""
tavily_key = "" # only needed for local/vLLM models without native search
[model]
name = "" # empty = auto-detect
temperature = 0.5
reasoning_effort = "medium"
default = "default" # model alias for new workstreams
fallback = [] # ordered list of fallback model aliases
agent_model = "" # model alias for plan/task sub-agents
[tools]
timeout = 30
skip_permissions = false
[server]
host = "0.0.0.0"
port = 8080
max_workstreams = 10 # auto-evicts oldest idle when full
[redis]
host = "localhost"
port = 6379
password = ""
[bridge]
server_url = "http://localhost:8080"
node_id = "" # empty = hostname_xxxx
[console]
host = "0.0.0.0"
port = 8090
url = "http://localhost:8090" # used by CLI /cluster commands
poll_interval = 10
[health]
backend_probe_interval = 30
backend_probe_timeout = 5
circuit_breaker_threshold = 5
circuit_breaker_cooldown = 60
[ratelimit]
enabled = true
requests_per_second = 10.0
burst = 20
[database]
backend = "sqlite" # "sqlite" (default) or "postgresql"
path = ".turnstone.db" # SQLite file path (relative to working directory)
# url = "postgresql+psycopg://user:pass@host:5432/turnstone" # PostgreSQL
# pool_size = 5 # PostgreSQL connection pool size
[mcp]
config_path = "" # path to MCP JSON config file (alternative to TOML sections)
[mcp.servers.example] # one section per MCP server
command = "npx"
args = ["-y", "@modelcontextprotocol/server-example"]
# type = "stdio" # "stdio" (default) or "http"
# url = "" # for HTTP transport
```
Precedence: CLI args > environment variables > config.toml > defaults.
## Workstreams
Parallel independent conversations, each with its own session and state:
| Symbol | State | Meaning |
|--------|-------|---------|
| `·` | idle | Waiting for input |
| `◌` | thinking | Model is generating |
| `▸` | running | Tool execution in progress |
| `◆` | attention | Waiting for approval |
| `✖` | error | Something went wrong |
Idle workstreams are automatically cleaned up after 2 hours (configurable). In multi-node deployments, workstream ownership is tracked in Redis — follow-up messages auto-route to the owning node.
## Monitoring
`/metrics` endpoint exposes Prometheus-format metrics:
- `turnstone_tokens_total{direction}` — prompt/completion token counters
- `turnstone_tool_calls_total{tool}` — per-tool invocation counts
- `turnstone_workstream_context_ratio{ws_id}` — per-workstream context utilization
- `turnstone_http_request_duration_seconds` — request latency histogram
- `turnstone_workstreams_by_state{state}` — workstream state gauges
- `turnstone_sse_connections_active` — current open SSE connections
- `turnstone_ratelimit_rejected_total` — requests rejected by rate limiter
- `turnstone_backend_up` — LLM backend reachability (0/1)
- `turnstone_circuit_state` — circuit breaker state (0=closed, 1=open, 2=half_open)
- `turnstone_workstreams_evicted_total` — workstreams auto-evicted at capacity
Per-workstream metrics are labeled by `ws_id` (bounded to 10 max workstreams).
### Health & Rate Limiting
**Health degradation.** A background `BackendHealthMonitor` probes the LLM backend every `backend_probe_interval` seconds. When the backend is unreachable, `/health` reports `"status": "degraded"` (HTTP 200) and the `turnstone_backend_up` gauge drops to 0.
**Circuit breaker.** After `circuit_breaker_threshold` consecutive probe failures the circuit opens (CLOSED -> OPEN). While open, `ChatSession._create_stream_with_retry` skips the backend entirely and returns an error. After `circuit_breaker_cooldown` seconds the circuit enters HALF_OPEN, allowing a single probe. A successful probe closes the circuit; a failure re-opens it.
**Per-IP rate limiting.** When `[ratelimit].enabled` is true, each client IP is tracked with a token-bucket limiter (`requests_per_second` / `burst`). Rate limiting is applied in `do_GET`/`do_POST` after authentication but before route dispatch. `/health` and `/metrics` are exempt. Requests that exceed the limit receive HTTP 429 with a `Retry-After` header.
**Workstream eviction.** When `WorkstreamManager.create()` would exceed `max_workstreams`, the oldest IDLE workstream is automatically evicted and the `turnstone_workstreams_evicted_total` counter is incremented. Configure via `[server].max_workstreams` (default 10).
| Topic | Link |
|-------|------|
| Configuration reference | [docs/settings.md](docs/settings.md) |
| API reference | [docs/api-reference.md](docs/api-reference.md) |
| Docker deployment | [docs/docker.md](docs/docker.md) |
| Intent validation (judge) | [docs/judge.md](docs/judge.md) |
| Governance & RBAC | [docs/governance.md](docs/governance.md) |
| OIDC SSO | [docs/oidc.md](docs/oidc.md) |
| TLS / mTLS | [docs/tls.md](docs/tls.md) |
| Channel integrations | [docs/channels.md](docs/channels.md) |
| 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-registry.md](docs/mcp-registry.md) |
## Requirements
- Python 3.11+
- An OpenAI-compatible API endpoint ([vLLM](https://github.com/vllm-project/vllm), [NVIDIA NIM](https://build.nvidia.com/), [llama.cpp](https://github.com/ggml-org/llama.cpp), etc.) or an Anthropic API key
- Redis (for message queue bridge — `pip install turnstone[mq]`)
- Anthropic provider (optional — `pip install turnstone[anthropic]`)
- PostgreSQL (optional, for production — `pip install turnstone[postgres]`)
- [Git LFS](https://git-lfs.com/) (for cloning — diagram PNGs are stored in LFS)
- 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)
## License
+116
View File
@@ -0,0 +1,116 @@
Turnstone — Third-Party Notices
This file contains the licenses and notices for third-party software bundled
with Turnstone. Each bundled dependency retains its original license; the
Turnstone BUSL-1.1 license does not apply to these components.
================================================================================
KaTeX 0.16.38
https://katex.org/
https://github.com/KaTeX/KaTeX
The MIT License (MIT)
Copyright (c) 2013-2020 Khan Academy and other contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
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.
================================================================================
highlight.js 11.11.1
https://highlightjs.org/
https://github.com/highlightjs/highlight.js
BSD 3-Clause License
Copyright (c) 2006, Ivan Sagalaev.
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
================================================================================
Mermaid 11.13.0
https://mermaid.js.org/
https://github.com/mermaid-js/mermaid
The MIT License (MIT)
Copyright (c) 2014-2022 Knut Sveidqvist
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
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.
+72 -299
View File
@@ -1,12 +1,16 @@
# =============================================================================
# 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:
# Default (SQLite): docker compose up
# Production (PG): DB_BACKEND=postgresql docker compose --profile production up
# (or set DB_BACKEND=postgresql in .env)
# Infra only: docker compose up
# Single node: docker compose --profile production up
# Production (PG): TURNSTONE_DB_BACKEND=postgresql docker compose --profile production up
# 10-node cluster: docker compose --profile cluster up
# With simulator: docker compose --profile sim up
# =============================================================================
name: turnstone
@@ -16,8 +20,8 @@ networks:
driver: bridge
volumes:
redis-data:
turnstone-data:
workspace:
postgres-data:
services:
@@ -25,14 +29,21 @@ services:
# PostgreSQL — production database (profile: production)
# -------------------------------------------------------------------
postgres:
image: postgres:17-alpine
image: pgautoupgrade/pgautoupgrade:18-alpine
profiles:
- production
- cluster
command:
- postgres
- -c
- max_connections=${POSTGRES_MAX_CONNECTIONS:-300}
- -c
- shared_buffers=128MB
environment:
POSTGRES_DB: turnstone
POSTGRES_USER: ${POSTGRES_USER:-turnstone}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required for production profile}
PGDATA: /var/lib/postgresql/data
volumes:
- postgres-data:/var/lib/postgresql/data
networks:
@@ -42,52 +53,21 @@ services:
interval: 5s
timeout: 3s
retries: 5
start_period: 5s
start_period: 30s
deploy:
resources:
limits:
memory: 512M
cpus: '1.0'
restart: unless-stopped
# -------------------------------------------------------------------
# Redis — message broker, pub/sub, node registry
# -------------------------------------------------------------------
redis:
image: redis:7.4-alpine
command:
- sh
- -c
- >-
redis-server
--save 60 1
--loglevel warning
$${REDIS_PASSWORD:+--requirepass $$REDIS_PASSWORD}
ports:
- "${REDIS_PORT:-6379}:6379"
environment:
- REDIS_PASSWORD=${REDIS_PASSWORD:-}
volumes:
- redis-data:/data
networks:
- turnstone-net
healthcheck:
test:
- CMD-SHELL
- redis-cli $${REDIS_PASSWORD:+-a $$REDIS_PASSWORD} ping | grep -q PONG
interval: 5s
timeout: 3s
retries: 5
start_period: 5s
memory: 4G
cpus: '4.0'
restart: unless-stopped
# -------------------------------------------------------------------
# turnstone-server — Web UI + chat workstreams + LLM interaction
# -------------------------------------------------------------------
server:
build:
context: .
dockerfile: Dockerfile
image: turnstone:local
profiles:
- production
command:
- sh
- -c
@@ -99,29 +79,30 @@ services:
--api-key "$${OPENAI_API_KEY}"
$${MODEL:+--model $$MODEL}
$${SKIP_PERMISSIONS:+--skip-permissions}
$${MCP_CONFIG:+--mcp-config $$MCP_CONFIG}
ports:
- "${SERVER_PORT:-8080}:8080"
volumes:
- turnstone-data:/data
- ${WORKSPACE_MOUNT:-workspace}:/workspace
environment:
- LLM_BASE_URL=${LLM_BASE_URL:-http://host.docker.internal:8000/v1}
- OPENAI_API_KEY=${OPENAI_API_KEY:-dummy}
- TAVILY_API_KEY=${TAVILY_API_KEY:-}
- SKIP_PERMISSIONS=${SKIP_PERMISSIONS:-}
- TURNSTONE_AUTH_ENABLED=${TURNSTONE_AUTH_ENABLED:-}
- TURNSTONE_AUTH_TOKEN=${TURNSTONE_AUTH_TOKEN:-}
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:-}
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:?Set TURNSTONE_JWT_SECRET in .env}
- MODEL=${MODEL:-}
- TURNSTONE_DB_BACKEND=${DB_BACKEND:-sqlite}
- TURNSTONE_DB_URL=${DATABASE_URL:-}
- MCP_CONFIG=${MCP_CONFIG:-}
- 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:
- "host.docker.internal:host-gateway"
networks:
- turnstone-net
depends_on:
redis:
condition: service_healthy
postgres:
condition: service_healthy
required: false
@@ -129,42 +110,15 @@ services:
test: ["CMD", "python", "/usr/local/bin/healthcheck.py", "http://127.0.0.1:8080/health"]
interval: 10s
timeout: 5s
retries: 3
start_period: 15s
restart: unless-stopped
# -------------------------------------------------------------------
# turnstone-bridge — Redis <-> HTTP bridge for multi-node routing
# Node ID auto-generated from container hostname (no --node-id needed)
# -------------------------------------------------------------------
bridge:
build:
context: .
dockerfile: Dockerfile
command:
- turnstone-bridge
- --server-url=http://server:8080
- --redis-host=redis
- --redis-port=6379
- --heartbeat-ttl=${HEARTBEAT_TTL:-60}
- --approval-timeout=${APPROVAL_TIMEOUT:-3600}
environment:
- REDIS_PASSWORD=${REDIS_PASSWORD:-}
- TURNSTONE_AUTH_TOKEN=${TURNSTONE_AUTH_TOKEN:-}
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:-}
networks:
- turnstone-net
depends_on:
server:
condition: service_healthy
redis:
condition: service_healthy
retries: 5
start_period: 60s
restart: unless-stopped
# -------------------------------------------------------------------
# turnstone-console — Cluster dashboard
# -------------------------------------------------------------------
console:
image: turnstone:local
build:
context: .
dockerfile: Dockerfile
@@ -172,23 +126,16 @@ services:
- turnstone-console
- --host=0.0.0.0
- --port=8090
- --redis-host=redis
- --redis-port=6379
- --poll-interval=${CONSOLE_POLL_INTERVAL:-10}
ports:
- "${CONSOLE_PORT:-8090}:8090"
environment:
- REDIS_PASSWORD=${REDIS_PASSWORD:-}
- TURNSTONE_AUTH_ENABLED=${TURNSTONE_AUTH_ENABLED:-}
- TURNSTONE_AUTH_TOKEN=${TURNSTONE_AUTH_TOKEN:-}
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:-}
- TURNSTONE_DB_BACKEND=${DB_BACKEND:-sqlite}
- TURNSTONE_DB_URL=${DATABASE_URL:-}
# 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=${TURNSTONE_DB_BACKEND:-sqlite}
- TURNSTONE_DB_URL=${TURNSTONE_DB_URL:-}
- TURNSTONE_CONSOLE_URL=http://console:8090
networks:
- turnstone-net
depends_on:
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "python", "/usr/local/bin/healthcheck.py", "http://127.0.0.1:8090/health"]
interval: 10s
@@ -202,83 +149,38 @@ services:
# Requires TURNSTONE_DISCORD_TOKEN to enable Discord adapter
# -------------------------------------------------------------------
channel:
build:
context: .
dockerfile: Dockerfile
image: turnstone:local
profiles:
- production
- cluster
command:
- sh
- -c
- >-
turnstone-channel
--redis-host=redis
--redis-port=6379
--http-host=0.0.0.0
$${TURNSTONE_DISCORD_GUILD:+--discord-guild $$TURNSTONE_DISCORD_GUILD}
environment:
- TURNSTONE_DISCORD_TOKEN=${TURNSTONE_DISCORD_TOKEN:-}
- TURNSTONE_DISCORD_GUILD=${TURNSTONE_DISCORD_GUILD:-0}
- REDIS_PASSWORD=${REDIS_PASSWORD:-}
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:-}
- TURNSTONE_DB_BACKEND=${DB_BACKEND:-sqlite}
- TURNSTONE_DB_URL=${DATABASE_URL:-}
# 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=${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
depends_on:
redis:
condition: service_healthy
postgres:
condition: service_healthy
required: false
restart: unless-stopped
# -------------------------------------------------------------------
# turnstone-sim — Multi-node cluster simulator (no LLM needed)
# Start with: docker compose --profile sim up
# -------------------------------------------------------------------
sim:
build:
context: .
dockerfile: Dockerfile
profiles:
- sim
command:
- sh
- -c
- >-
turnstone-sim
--nodes "$${SIM_NODES}"
--scenario "$${SIM_SCENARIO}"
--duration "$${SIM_DURATION}"
--mps "$${SIM_MPS}"
--redis-host redis
--redis-port 6379
--log-level "$${SIM_LOG_LEVEL}"
$${SIM_SEED:+--seed $$SIM_SEED}
$${SIM_METRICS_FILE:+--metrics-file $$SIM_METRICS_FILE}
environment:
- REDIS_PASSWORD=${REDIS_PASSWORD:-}
- SIM_NODES=${SIM_NODES:-100}
- SIM_SCENARIO=${SIM_SCENARIO:-steady}
- SIM_DURATION=${SIM_DURATION:-60}
- SIM_MPS=${SIM_MPS:-5.0}
- SIM_LOG_LEVEL=${SIM_LOG_LEVEL:-INFO}
- SIM_SEED=${SIM_SEED:-}
- SIM_METRICS_FILE=${SIM_METRICS_FILE:-}
networks:
- turnstone-net
depends_on:
redis:
condition: service_healthy
restart: "no"
# ===================================================================
# 10-node cluster (profile: cluster)
#
# Each node is a server + bridge pair. All share the same PostgreSQL
# and Redis instances. Access via console at :8090.
# All nodes share the same PostgreSQL instance.
# Access via console at :8090.
#
# Start: docker compose --profile cluster up
# ===================================================================
@@ -286,9 +188,10 @@ services:
# -- cluster servers ------------------------------------------------
server-1: &cluster-server
image: turnstone:local
build: { context: ., dockerfile: Dockerfile }
profiles: [cluster]
command: &cluster-server-cmd
command:
- sh
- -c
- >-
@@ -299,193 +202,63 @@ services:
--api-key "$${OPENAI_API_KEY}"
$${MODEL:+--model $$MODEL}
$${SKIP_PERMISSIONS:+--skip-permissions}
volumes: [turnstone-data:/data]
$${MCP_CONFIG:+--mcp-config $$MCP_CONFIG}
volumes:
- turnstone-data:/data
- ${WORKSPACE_MOUNT:-workspace}:/workspace
environment: &cluster-server-env
LLM_BASE_URL: ${LLM_BASE_URL:-http://host.docker.internal:8000/v1}
OPENAI_API_KEY: ${OPENAI_API_KEY:-dummy}
TAVILY_API_KEY: ${TAVILY_API_KEY:-}
SKIP_PERMISSIONS: ${SKIP_PERMISSIONS:-}
TURNSTONE_AUTH_ENABLED: ${TURNSTONE_AUTH_ENABLED:-}
TURNSTONE_AUTH_TOKEN: ${TURNSTONE_AUTH_TOKEN:-}
TURNSTONE_JWT_SECRET: ${TURNSTONE_JWT_SECRET:-}
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
TURNSTONE_JWT_SECRET: ${TURNSTONE_JWT_SECRET:?Set TURNSTONE_JWT_SECRET in .env}
MODEL: ${MODEL:-}
TURNSTONE_DB_BACKEND: ${DB_BACKEND:-postgresql}
TURNSTONE_DB_URL: ${DATABASE_URL:-postgresql://${POSTGRES_USER:-turnstone}:${POSTGRES_PASSWORD:?}@postgres:5432/turnstone}
MCP_CONFIG: ${MCP_CONFIG:-}
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"]
networks: [turnstone-net]
depends_on:
redis: { condition: service_healthy }
postgres: { condition: service_healthy }
healthcheck:
test: ["CMD", "python", "/usr/local/bin/healthcheck.py", "http://127.0.0.1:8080/health"]
interval: 10s
timeout: 5s
retries: 3
start_period: 15s
retries: 5
start_period: 60s
deploy:
resources:
limits: { memory: 384M, cpus: '0.5' }
limits: { memory: 4G, cpus: '4' }
restart: unless-stopped
server-2:
<<: *cluster-server
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-2 }
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-2, TURNSTONE_ADVERTISE_URL: "http://server-2:8080" }
server-3:
<<: *cluster-server
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-3 }
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-3, TURNSTONE_ADVERTISE_URL: "http://server-3:8080" }
server-4:
<<: *cluster-server
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-4 }
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-4, TURNSTONE_ADVERTISE_URL: "http://server-4:8080" }
server-5:
<<: *cluster-server
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-5 }
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-5, TURNSTONE_ADVERTISE_URL: "http://server-5:8080" }
server-6:
<<: *cluster-server
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-6 }
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-6, TURNSTONE_ADVERTISE_URL: "http://server-6:8080" }
server-7:
<<: *cluster-server
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-7 }
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-7, TURNSTONE_ADVERTISE_URL: "http://server-7:8080" }
server-8:
<<: *cluster-server
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-8 }
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-8, TURNSTONE_ADVERTISE_URL: "http://server-8:8080" }
server-9:
<<: *cluster-server
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-9 }
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-9, TURNSTONE_ADVERTISE_URL: "http://server-9:8080" }
server-10:
<<: *cluster-server
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-10 }
environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-10, TURNSTONE_ADVERTISE_URL: "http://server-10:8080" }
# -- cluster bridges ------------------------------------------------
bridge-1: &cluster-bridge
build: { context: ., dockerfile: Dockerfile }
profiles: [cluster]
command:
- turnstone-bridge
- --server-url=http://server-1:8080
- --redis-host=redis
- --redis-port=6379
- --heartbeat-ttl=${HEARTBEAT_TTL:-60}
- --approval-timeout=${APPROVAL_TIMEOUT:-3600}
environment: &cluster-bridge-env
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
TURNSTONE_AUTH_TOKEN: ${TURNSTONE_AUTH_TOKEN:-}
TURNSTONE_JWT_SECRET: ${TURNSTONE_JWT_SECRET:-}
networks: [turnstone-net]
depends_on:
server-1: { condition: service_healthy }
redis: { condition: service_healthy }
deploy:
resources:
limits: { memory: 256M, cpus: '0.25' }
restart: unless-stopped
bridge-2:
<<: *cluster-bridge
command:
- turnstone-bridge
- --server-url=http://server-2:8080
- --redis-host=redis
- --redis-port=6379
- --heartbeat-ttl=${HEARTBEAT_TTL:-60}
- --approval-timeout=${APPROVAL_TIMEOUT:-3600}
depends_on:
server-2: { condition: service_healthy }
redis: { condition: service_healthy }
bridge-3:
<<: *cluster-bridge
command:
- turnstone-bridge
- --server-url=http://server-3:8080
- --redis-host=redis
- --redis-port=6379
- --heartbeat-ttl=${HEARTBEAT_TTL:-60}
- --approval-timeout=${APPROVAL_TIMEOUT:-3600}
depends_on:
server-3: { condition: service_healthy }
redis: { condition: service_healthy }
bridge-4:
<<: *cluster-bridge
command:
- turnstone-bridge
- --server-url=http://server-4:8080
- --redis-host=redis
- --redis-port=6379
- --heartbeat-ttl=${HEARTBEAT_TTL:-60}
- --approval-timeout=${APPROVAL_TIMEOUT:-3600}
depends_on:
server-4: { condition: service_healthy }
redis: { condition: service_healthy }
bridge-5:
<<: *cluster-bridge
command:
- turnstone-bridge
- --server-url=http://server-5:8080
- --redis-host=redis
- --redis-port=6379
- --heartbeat-ttl=${HEARTBEAT_TTL:-60}
- --approval-timeout=${APPROVAL_TIMEOUT:-3600}
depends_on:
server-5: { condition: service_healthy }
redis: { condition: service_healthy }
bridge-6:
<<: *cluster-bridge
command:
- turnstone-bridge
- --server-url=http://server-6:8080
- --redis-host=redis
- --redis-port=6379
- --heartbeat-ttl=${HEARTBEAT_TTL:-60}
- --approval-timeout=${APPROVAL_TIMEOUT:-3600}
depends_on:
server-6: { condition: service_healthy }
redis: { condition: service_healthy }
bridge-7:
<<: *cluster-bridge
command:
- turnstone-bridge
- --server-url=http://server-7:8080
- --redis-host=redis
- --redis-port=6379
- --heartbeat-ttl=${HEARTBEAT_TTL:-60}
- --approval-timeout=${APPROVAL_TIMEOUT:-3600}
depends_on:
server-7: { condition: service_healthy }
redis: { condition: service_healthy }
bridge-8:
<<: *cluster-bridge
command:
- turnstone-bridge
- --server-url=http://server-8:8080
- --redis-host=redis
- --redis-port=6379
- --heartbeat-ttl=${HEARTBEAT_TTL:-60}
- --approval-timeout=${APPROVAL_TIMEOUT:-3600}
depends_on:
server-8: { condition: service_healthy }
redis: { condition: service_healthy }
bridge-9:
<<: *cluster-bridge
command:
- turnstone-bridge
- --server-url=http://server-9:8080
- --redis-host=redis
- --redis-port=6379
- --heartbeat-ttl=${HEARTBEAT_TTL:-60}
- --approval-timeout=${APPROVAL_TIMEOUT:-3600}
depends_on:
server-9: { condition: service_healthy }
redis: { condition: service_healthy }
bridge-10:
<<: *cluster-bridge
command:
- turnstone-bridge
- --server-url=http://server-10:8080
- --redis-host=redis
- --redis-port=6379
- --heartbeat-ttl=${HEARTBEAT_TTL:-60}
- --approval-timeout=${APPROVAL_TIMEOUT:-3600}
depends_on:
server-10: { condition: service_healthy }
redis: { condition: service_healthy }
-221
View File
@@ -1,221 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 860 520" font-family="ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace" font-size="13">
<style>
@keyframes pulse-green { 0%,100% { opacity:0.5 } 50% { opacity:1 } }
@keyframes pulse-yellow { 0%,100% { opacity:0.4 } 50% { opacity:1 } }
@keyframes pulse-blue { 0%,100% { opacity:0.3 } 50% { opacity:1 } }
@keyframes fadein { from { opacity:0 } to { opacity:1 } }
.pg { animation: pulse-green 2s infinite }
.py { animation: pulse-yellow 1.8s infinite }
.pb { animation: pulse-blue 2.2s infinite }
.f1 { animation: fadein 0.4s 0.2s both }
.f2 { animation: fadein 0.4s 0.4s both }
.f3 { animation: fadein 0.4s 0.6s both }
.f4 { animation: fadein 0.4s 0.8s both }
.f5 { animation: fadein 0.4s 1.0s both }
.f6 { animation: fadein 0.4s 1.3s both }
.f7 { animation: fadein 0.4s 1.5s both }
.f8 { animation: fadein 0.4s 1.7s both }
.f9 { animation: fadein 0.4s 1.9s both }
.f10 { animation: fadein 0.4s 2.1s both }
.f11 { animation: fadein 0.4s 2.3s both }
.f12 { animation: fadein 0.4s 2.5s both }
</style>
<!-- Window chrome -->
<rect rx="10" width="860" height="520" fill="#1a1b26"/>
<rect width="860" height="36" rx="10" fill="#16161e"/>
<rect y="26" width="860" height="10" fill="#16161e"/>
<circle cx="20" cy="18" r="6" fill="#f7768e"/>
<circle cx="40" cy="18" r="6" fill="#e0af68"/>
<circle cx="60" cy="18" r="6" fill="#9ece6a"/>
<text x="430" y="22" text-anchor="middle" fill="#565f89" font-size="12">turnstone — console</text>
<!-- Header -->
<rect y="36" width="860" height="30" fill="#24283b"/>
<rect y="66" width="860" height="1" fill="#3b4261"/>
<text x="16" y="56" fill="#7aa2f7" font-size="14" font-weight="bold">turnstone console</text>
<text x="200" y="56" fill="#565f89" font-size="12">6 nodes · 10 workstreams</text>
<!-- ====== State cards ====== -->
<g transform="translate(16, 78)" class="f1" opacity="0">
<!-- RUN card -->
<rect x="0" y="0" width="156" height="64" rx="6" fill="#24283b" stroke="#3b4261"/>
<rect x="0" y="0" width="156" height="3" rx="6" fill="#9ece6a"/>
<text x="78" y="30" text-anchor="middle" fill="#a9b1d6" font-size="22" font-weight="bold">3</text>
<text x="78" y="50" text-anchor="middle" fill="#565f89" font-size="10">▸ RUN</text>
<!-- THINK card -->
<rect x="168" y="0" width="156" height="64" rx="6" fill="#24283b" stroke="#3b4261"/>
<rect x="168" y="0" width="156" height="3" rx="6" fill="#7aa2f7"/>
<text x="246" y="30" text-anchor="middle" fill="#a9b1d6" font-size="22" font-weight="bold">2</text>
<text x="246" y="50" text-anchor="middle" fill="#565f89" font-size="10">◌ THINK</text>
<!-- ATTN card -->
<rect x="336" y="0" width="156" height="64" rx="6" fill="#24283b" stroke="#3b4261"/>
<rect x="336" y="0" width="156" height="3" rx="6" fill="#e0af68"/>
<text x="414" y="30" text-anchor="middle" fill="#a9b1d6" font-size="22" font-weight="bold">1</text>
<text x="414" y="50" text-anchor="middle" fill="#565f89" font-size="10">◆ ATTN</text>
<!-- ERR card -->
<rect x="504" y="0" width="156" height="64" rx="6" fill="#24283b" stroke="#3b4261"/>
<rect x="504" y="0" width="156" height="3" rx="6" fill="#f7768e"/>
<text x="582" y="30" text-anchor="middle" fill="#a9b1d6" font-size="22" font-weight="bold">0</text>
<text x="582" y="50" text-anchor="middle" fill="#565f89" font-size="10">✖ ERR</text>
<!-- IDLE card -->
<rect x="672" y="0" width="156" height="64" rx="6" fill="#24283b" stroke="#3b4261"/>
<rect x="672" y="0" width="156" height="3" rx="6" fill="#565f89"/>
<text x="750" y="30" text-anchor="middle" fill="#a9b1d6" font-size="22" font-weight="bold">4</text>
<text x="750" y="50" text-anchor="middle" fill="#565f89" font-size="10">· IDLE</text>
</g>
<!-- Aggregate bar -->
<text x="16" y="160" fill="#565f89" font-size="11" class="f2" opacity="0">197k tokens · 42 tool calls</text>
<!-- ====== NODES section ====== -->
<text x="16" y="182" fill="#7aa2f7" font-size="12" font-weight="bold" class="f3" opacity="0">NODES</text>
<!-- Node column headers -->
<g transform="translate(0, 190)" class="f4" opacity="0">
<rect width="860" height="20" fill="#24283b"/>
<rect y="20" width="860" height="1" fill="#3b4261"/>
<text y="14" fill="#565f89" font-size="10" letter-spacing="0.5">
<tspan x="36">NODE</tspan>
<tspan x="560">WS</tspan>
<tspan x="610">RUN</tspan>
<tspan x="660">ATTN</tspan>
<tspan x="710">TOKENS</tspan>
<tspan x="790">LOAD</tspan>
</text>
</g>
<!-- Node rows -->
<g transform="translate(0, 214)">
<!-- Node 1: db-west-04 — 3 ws, 1 running, has-running bar -->
<g class="f5" opacity="0">
<rect y="0" width="860" height="38" fill="#1a1b26"/>
<rect y="0" width="3" height="38" fill="#9ece6a"/>
<circle cx="22" cy="19" r="4" fill="#9ece6a"/>
<text x="36" y="23" fill="#a9b1d6" font-size="12" font-weight="bold">db-west-04</text>
<text x="566" y="23" fill="#a9b1d6" font-size="11">3</text>
<text x="616" y="23" fill="#a9b1d6" font-size="11">1</text>
<text x="666" y="23" fill="#565f89" font-size="11">0</text>
<text x="710" y="23" fill="#565f89" font-size="11">57.6k</text>
<!-- Load bar: 3/10 = 30% -->
<rect x="770" y="15" width="60" height="6" rx="3" fill="#292e42"/>
<rect x="770" y="15" width="18" height="6" rx="3" fill="#9ece6a"/>
<text x="838" y="23" fill="#565f89" font-size="11">30%</text>
</g>
<!-- Node 2: api-east-01 — 3 ws, 1 attention, has-attention bar -->
<g class="f6" opacity="0">
<rect y="40" width="860" height="38" fill="#24283b"/>
<rect y="40" width="3" height="38" fill="#e0af68"/>
<circle cx="22" cy="59" r="4" fill="#9ece6a"/>
<text x="36" y="63" fill="#a9b1d6" font-size="12" font-weight="bold">api-east-01</text>
<text x="566" y="63" fill="#a9b1d6" font-size="11">3</text>
<text x="616" y="63" fill="#565f89" font-size="11">0</text>
<text x="666" y="63" fill="#a9b1d6" font-size="11">1</text>
<text x="710" y="63" fill="#565f89" font-size="11">109k</text>
<!-- Load bar: 3/10 = 30% -->
<rect x="770" y="55" width="60" height="6" rx="3" fill="#292e42"/>
<rect x="770" y="55" width="18" height="6" rx="3" fill="#9ece6a"/>
<text x="838" y="63" fill="#565f89" font-size="11">30%</text>
</g>
<!-- Node 3: sre-node-03 — 2 ws, 1 running, has-running bar -->
<g class="f7" opacity="0">
<rect y="80" width="860" height="38" fill="#1a1b26"/>
<rect y="80" width="3" height="38" fill="#9ece6a"/>
<circle cx="22" cy="99" r="4" fill="#9ece6a"/>
<text x="36" y="103" fill="#a9b1d6" font-size="12" font-weight="bold">sre-node-03</text>
<text x="566" y="103" fill="#a9b1d6" font-size="11">2</text>
<text x="616" y="103" fill="#a9b1d6" font-size="11">1</text>
<text x="666" y="103" fill="#565f89" font-size="11">0</text>
<text x="710" y="103" fill="#565f89" font-size="11">64.4k</text>
<!-- Load bar: 2/10 = 20% -->
<rect x="770" y="95" width="60" height="6" rx="3" fill="#292e42"/>
<rect x="770" y="95" width="12" height="6" rx="3" fill="#9ece6a"/>
<text x="838" y="103" fill="#565f89" font-size="11">20%</text>
</g>
<!-- Node 4: analytics-02 — 1 ws, thinking, has-thinking bar -->
<g class="f8" opacity="0">
<rect y="120" width="860" height="38" fill="#24283b"/>
<rect y="120" width="3" height="38" fill="#7aa2f7"/>
<circle cx="22" cy="139" r="4" fill="#9ece6a"/>
<text x="36" y="143" fill="#a9b1d6" font-size="12" font-weight="bold">analytics-02</text>
<text x="566" y="143" fill="#a9b1d6" font-size="11">1</text>
<text x="616" y="143" fill="#565f89" font-size="11">0</text>
<text x="666" y="143" fill="#565f89" font-size="11">0</text>
<text x="710" y="143" fill="#565f89" font-size="11">18.3k</text>
<!-- Load bar: 1/10 = 10% -->
<rect x="770" y="135" width="60" height="6" rx="3" fill="#292e42"/>
<rect x="770" y="135" width="6" height="6" rx="3" fill="#9ece6a"/>
<text x="838" y="143" fill="#565f89" font-size="11">10%</text>
</g>
<!-- Node 5: data-ops-05 — 1 ws, thinking, has-thinking bar -->
<g class="f9" opacity="0">
<rect y="160" width="860" height="38" fill="#1a1b26"/>
<rect y="160" width="3" height="38" fill="#7aa2f7"/>
<circle cx="22" cy="179" r="4" fill="#9ece6a"/>
<text x="36" y="183" fill="#a9b1d6" font-size="12" font-weight="bold">data-ops-05</text>
<text x="566" y="183" fill="#a9b1d6" font-size="11">1</text>
<text x="616" y="183" fill="#565f89" font-size="11">0</text>
<text x="666" y="183" fill="#565f89" font-size="11">0</text>
<text x="710" y="183" fill="#565f89" font-size="11">8.7k</text>
<!-- Load bar: 1/10 = 10% -->
<rect x="770" y="175" width="60" height="6" rx="3" fill="#292e42"/>
<rect x="770" y="175" width="6" height="6" rx="3" fill="#9ece6a"/>
<text x="838" y="183" fill="#565f89" font-size="11">10%</text>
</g>
<!-- Node 6: ml-gpu-07 — 0 ws, empty, no bar -->
<g class="f10" opacity="0">
<rect y="200" width="860" height="38" fill="#24283b"/>
<rect y="200" width="3" height="38" fill="transparent"/>
<circle cx="22" cy="219" r="4" fill="#9ece6a"/>
<text x="36" y="223" fill="#a9b1d6" font-size="12" font-weight="bold">ml-gpu-07</text>
<text x="566" y="223" fill="#565f89" font-size="11">0</text>
<text x="616" y="223" fill="#565f89" font-size="11">0</text>
<text x="666" y="223" fill="#565f89" font-size="11">0</text>
<text x="710" y="223" fill="#565f89" font-size="11">0</text>
<!-- Load bar: 0/10 = 0% (empty track) -->
<rect x="770" y="215" width="60" height="6" rx="3" fill="#292e42"/>
<text x="842" y="223" fill="#565f89" font-size="11">0%</text>
</g>
</g>
<!-- ====== Footer ====== -->
<g transform="translate(0, 468)" class="f12" opacity="0">
<rect width="860" height="1" fill="#3b4261"/>
<rect y="1" width="860" height="24" fill="#16161e"/>
<circle cx="20" cy="14" r="3" fill="#9ece6a"/>
<text x="28" y="18" fill="#565f89" font-size="10">db-west-04</text>
<circle cx="120" cy="14" r="3" fill="#9ece6a"/>
<text x="128" y="18" fill="#565f89" font-size="10">api-east-01</text>
<circle cx="225" cy="14" r="3" fill="#9ece6a"/>
<text x="233" y="18" fill="#565f89" font-size="10">sre-node-03</text>
<circle cx="335" cy="14" r="3" fill="#9ece6a"/>
<text x="343" y="18" fill="#565f89" font-size="10">analytics-02</text>
<circle cx="450" cy="14" r="3" fill="#9ece6a"/>
<text x="458" y="18" fill="#565f89" font-size="10">data-ops-05</text>
<circle cx="560" cy="14" r="3" fill="#9ece6a"/>
<text x="568" y="18" fill="#565f89" font-size="10">ml-gpu-07</text>
<text x="680" y="18" fill="#3b4261" font-size="10">258k tokens · 42 calls · 12m</text>
</g>
<!-- Bottom edge -->
<rect y="493" width="860" height="27" fill="#16161e"/>
<rect y="510" width="860" height="10" rx="10" fill="#16161e"/>
</svg>

Before

Width:  |  Height:  |  Size: 11 KiB

+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}"
+85
View File
@@ -0,0 +1,85 @@
# TLS overlay — enables mTLS across the turnstone cluster.
#
# Usage (requires base compose.yaml with production profile):
# docker compose -f compose.yaml -f deploy/docker-compose.tls.yml --profile production up
#
# The tls-init service bootstraps a CA and issues certs.
# All turnstone services auto-provision their own certs via the
# console's ACME endpoint.
services:
# Bootstrap: create CA before anything starts.
# Runs as root to create directories in the volume, then chowns
# to turnstone:turnstone with restrictive perms (keys 0600).
tls-init:
build: .
user: root
command:
- sh
- -c
- |
set -e
turnstone-admin tls-bootstrap --out /certs
chown -R turnstone:turnstone /certs
find /certs -type d -exec chmod 750 {} +
find /certs -type f -name '*key.pem' -exec chmod 600 {} +
find /certs -type f ! -name '*key.pem' -exec chmod 640 {} +
volumes:
- tls-certs:/certs
networks:
- turnstone-net
restart: "no"
# Console: runs the internal CA + ACME server
console:
depends_on:
tls-init:
condition: service_completed_successfully
volumes:
- tls-certs:/certs:ro
environment:
TURNSTONE_TLS_ENABLED: "true"
TURNSTONE_TLS_SANS: "console"
TURNSTONE_CONSOLE_URL: "http://console:8090"
command:
- turnstone-console
- --host=0.0.0.0
- --port=8090
- --poll-interval=${CONSOLE_POLL_INTERVAL:-10}
# Server: auto-provisions certs via console ACME, serves HTTPS
server:
depends_on:
console:
condition: service_healthy
volumes:
- tls-certs:/certs:ro
environment:
TURNSTONE_TLS_ENABLED: "true"
TURNSTONE_TLS_SANS: "server"
# Disable healthcheck — server serves HTTPS with mTLS which the
# stdlib healthcheck script can't satisfy. The base compose
# healthcheck uses plain HTTP which won't work on an HTTPS listener.
healthcheck:
disable: true
# Channel: TLS
channel:
depends_on:
console:
condition: service_healthy
volumes:
- tls-certs:/certs:ro
environment:
TURNSTONE_TLS_ENABLED: "true"
TURNSTONE_TLS_SANS: "channel"
command:
- sh
- -c
- >-
turnstone-channel
--http-host=0.0.0.0
$${TURNSTONE_DISCORD_GUILD:+--discord-guild $$TURNSTONE_DISCORD_GUILD}
volumes:
tls-certs:
+1 -5
View File
@@ -7,10 +7,6 @@ appVersion: "0.3.0"
dependencies:
- name: postgresql
version: ~16.0
version: ~18.6.0
repository: https://charts.bitnami.com/bitnami
condition: postgresql.enabled
- name: redis
version: ~20.0
repository: https://charts.bitnami.com/bitnami
condition: redis.enabled
@@ -25,14 +25,10 @@ Then open: http://localhost:{{ .Values.console.service.port }}
Components deployed:
- Server: {{ include "turnstone.fullname" . }}-server ({{ .Values.server.replicas }} replica(s))
- Bridge: {{ include "turnstone.fullname" . }}-bridge ({{ .Values.bridge.replicas }} replica(s))
- Console: {{ include "turnstone.fullname" . }}-console ({{ .Values.console.replicas }} replica(s))
{{- if .Values.postgresql.enabled }}
- PostgreSQL (bitnami subchart)
{{- end }}
{{- if .Values.redis.enabled }}
- Redis (bitnami subchart)
{{- end }}
{{- if not .Values.llm.apiKey }}
{{- if not .Values.llm.existingSecret }}
@@ -110,28 +110,6 @@ Determine the PostgreSQL username.
{{- end }}
{{- end }}
{{/*
Determine the Redis host.
*/}}
{{- define "turnstone.redis.host" -}}
{{- if .Values.redis.enabled }}
{{- printf "%s-redis-master" .Release.Name }}
{{- else }}
{{- .Values.redis.external.host }}
{{- end }}
{{- end }}
{{/*
Determine the Redis port.
*/}}
{{- define "turnstone.redis.port" -}}
{{- if .Values.redis.enabled }}
{{- printf "6379" }}
{{- else }}
{{- .Values.redis.external.port | toString }}
{{- end }}
{{- end }}
{{/*
Determine the secret name for LLM API keys.
*/}}
@@ -14,8 +14,6 @@ data:
TURNSTONE_SERVER_PORT: {{ .Values.server.service.port | quote }}
TURNSTONE_CONSOLE_HOST: "0.0.0.0"
TURNSTONE_CONSOLE_PORT: {{ .Values.console.service.port | quote }}
TURNSTONE_REDIS_HOST: {{ include "turnstone.redis.host" . | quote }}
TURNSTONE_REDIS_PORT: {{ include "turnstone.redis.port" . | quote }}
TURNSTONE_POLL_INTERVAL: "5"
{{- if .Values.llm.baseUrl }}
TURNSTONE_LLM_BASE_URL: {{ .Values.llm.baseUrl | quote }}
@@ -1,45 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "turnstone.fullname" . }}-bridge
labels:
{{- include "turnstone.labels" . | nindent 4 }}
app.kubernetes.io/component: bridge
spec:
replicas: {{ .Values.bridge.replicas }}
selector:
matchLabels:
{{- include "turnstone.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: bridge
template:
metadata:
labels:
{{- include "turnstone.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: bridge
spec:
serviceAccountName: {{ include "turnstone.serviceAccountName" . }}
containers:
- name: bridge
image: {{ include "turnstone.image" . }}
imagePullPolicy: {{ .Values.image.pullPolicy }}
command:
- turnstone-bridge
- --server-url={{ printf "http://%s-server:%s" (include "turnstone.fullname" .) (.Values.server.service.port | toString) }}
- --redis-host={{ include "turnstone.redis.host" . }}
- --redis-port={{ include "turnstone.redis.port" . }}
envFrom:
- configMapRef:
name: {{ include "turnstone.fullname" . }}-config
- secretRef:
name: {{ include "turnstone.llm.secretName" . }}
optional: true
{{- if and .Values.auth.enabled .Values.auth.existingSecret }}
env:
- name: TURNSTONE_AUTH_TOKEN
valueFrom:
secretKeyRef:
name: {{ .Values.auth.existingSecret }}
key: TURNSTONE_AUTH_TOKEN
{{- end }}
resources:
{{- toYaml .Values.bridge.resources | nindent 12 }}
@@ -26,8 +26,6 @@ spec:
- turnstone-console
- --host=0.0.0.0
- --port={{ .Values.console.service.port }}
- --redis-host={{ include "turnstone.redis.host" . }}
- --redis-port={{ include "turnstone.redis.port" . }}
ports:
- name: http
containerPort: {{ .Values.console.service.port }}
@@ -38,13 +36,13 @@ spec:
- secretRef:
name: {{ include "turnstone.llm.secretName" . }}
optional: true
{{- if and .Values.auth.enabled .Values.auth.existingSecret }}
{{- if or .Values.auth.existingSecret .Values.auth.jwtSecret }}
env:
- name: TURNSTONE_AUTH_TOKEN
- name: TURNSTONE_JWT_SECRET
valueFrom:
secretKeyRef:
name: {{ .Values.auth.existingSecret }}
key: TURNSTONE_AUTH_TOKEN
name: {{ include "turnstone.auth.secretName" . }}
key: TURNSTONE_JWT_SECRET
{{- end }}
readinessProbe:
httpGet:
@@ -41,12 +41,12 @@ spec:
env:
- name: TURNSTONE_DB_URL
value: "postgresql+psycopg://$(TURNSTONE_DB_USER):$(POSTGRES_PASSWORD)@$(TURNSTONE_DB_HOST):$(TURNSTONE_DB_PORT)/$(TURNSTONE_DB_NAME)"
{{- if and .Values.auth.enabled .Values.auth.existingSecret }}
- name: TURNSTONE_AUTH_TOKEN
{{- if or .Values.auth.existingSecret .Values.auth.jwtSecret }}
- name: TURNSTONE_JWT_SECRET
valueFrom:
secretKeyRef:
name: {{ .Values.auth.existingSecret }}
key: TURNSTONE_AUTH_TOKEN
name: {{ include "turnstone.auth.secretName" . }}
key: TURNSTONE_JWT_SECRET
{{- end }}
readinessProbe:
httpGet:
+2 -9
View File
@@ -15,14 +15,7 @@ data:
{{- else if and (not .Values.postgresql.enabled) .Values.database.external.password }}
POSTGRES_PASSWORD: {{ .Values.database.external.password | b64enc | quote }}
{{- end }}
{{- if and .Values.auth.enabled .Values.auth.token (not .Values.auth.existingSecret) }}
TURNSTONE_AUTH_TOKEN: {{ .Values.auth.token | b64enc | quote }}
{{- end }}
{{- if and .Values.redis.enabled .Values.redis.auth }}
{{- if .Values.redis.auth.password }}
REDIS_PASSWORD: {{ .Values.redis.auth.password | b64enc | quote }}
{{- end }}
{{- else if and (not .Values.redis.enabled) .Values.redis.external.password }}
REDIS_PASSWORD: {{ .Values.redis.external.password | b64enc | quote }}
{{- if and .Values.auth.jwtSecret (not .Values.auth.existingSecret) }}
TURNSTONE_JWT_SECRET: {{ .Values.auth.jwtSecret | b64enc | quote }}
{{- end }}
{{- end }}
+2 -24
View File
@@ -24,16 +24,6 @@ postgresql:
database: turnstone
username: turnstone
# -- Redis configuration
redis:
enabled: true
architecture: standalone
# External Redis settings (used when redis.enabled is false)
external:
host: ""
port: 6379
existingSecret: ""
# -- Turnstone server (main API + web UI)
server:
replicas: 1
@@ -48,17 +38,6 @@ server:
type: ClusterIP
port: 8080
# -- Turnstone bridge (Redis MQ connector)
bridge:
replicas: 1
resources:
requests:
cpu: 250m
memory: 256Mi
limits:
cpu: "1"
memory: 512Mi
# -- Turnstone console (cluster dashboard)
console:
replicas: 1
@@ -80,10 +59,9 @@ llm:
apiKey: ""
existingSecret: ""
# -- Authentication
# -- Authentication (always enabled, JWT secret required)
auth:
enabled: false
token: ""
jwtSecret: ""
existingSecret: ""
# -- Ingress configuration
+49
View File
@@ -0,0 +1,49 @@
# OpenShell inference routing for Turnstone.
#
# When using inference routing, the sandbox process connects to
# https://inference.local instead of the real LLM API. The OpenShell
# proxy intercepts, rewrites credentials, and forwards to the backend.
#
# This keeps real API keys out of the sandbox entirely — the process
# only sees opaque placeholder tokens in its environment.
#
# Usage:
# openshell sandbox run \
# --inference-routes deploy/openshell/routes.yaml \
# ...
#
# Then start turnstone with:
# python3 -m turnstone.server --base-url https://inference.local
#
# CUSTOMIZE: uncomment one of the provider blocks below.
routes:
# --- OpenAI ---
# - name: inference.local
# endpoint: https://api.openai.com/v1
# model: gpt-5
# provider_type: openai
# protocols:
# - openai_chat_completions
# - model_discovery
# api_key_env: OPENAI_API_KEY
# --- Anthropic ---
# - name: inference.local
# endpoint: https://api.anthropic.com
# model: claude-sonnet-4-6
# provider_type: anthropic
# protocols:
# - anthropic_messages
# api_key_env: ANTHROPIC_API_KEY
# --- Local model server (vLLM / llama.cpp) ---
# No secret resolution needed — local servers typically have no auth.
# Omit both api_key and api_key_env to skip credential injection.
# - name: inference.local
# endpoint: http://localhost:8000/v1
# model: meta-llama/Llama-3.1-70B-Instruct
# protocols:
# - openai_chat_completions
# - model_discovery
+318
View File
@@ -0,0 +1,318 @@
# OpenShell sandbox policy for Turnstone AI orchestration platform.
#
# This policy wraps a turnstone-server process (the primary sandbox target).
# The console and channel gateway are separate processes that would each
# need their own sandbox with a tailored policy variant.
#
# Usage:
# openshell sandbox run \
# --policy deploy/openshell/turnstone-policy.yaml \
# --workdir /project \
# -- python3 -m turnstone.server --host 0.0.0.0 --port 8080
#
# For inference routing (keeps real API keys out of the sandbox):
# openshell sandbox run \
# --policy deploy/openshell/turnstone-policy.yaml \
# --inference-routes deploy/openshell/routes.yaml \
# --workdir /project \
# -- python3 -m turnstone.server --host 0.0.0.0 --port 8080 \
# --base-url https://inference.local
#
# Note: inference.local is intercepted by the OpenShell proxy before
# network policy evaluation — no network_policies entry is needed for it.
#
# Customization points (search for "CUSTOMIZE"):
# - OIDC issuer endpoint
# - MCP HTTP server endpoints
# - Additional tool binaries
# - web_fetch domain allowlist
version: 1
# ---------------------------------------------------------------------------
# Filesystem: Landlock kernel enforcement
# ---------------------------------------------------------------------------
# Static — cannot be changed after sandbox creation.
# include_workdir adds the --workdir path to read_write automatically.
filesystem_policy:
include_workdir: true
read_only:
# Python runtime + installed packages (includes turnstone package)
- /usr
- /lib
- /lib64
# System essentials
- /etc
- /proc
- /dev/urandom
# Turnstone config (read-only — writes go to database)
# CUSTOMIZE: adjust if config lives elsewhere
- /home/sandbox/.config/turnstone
read_write:
# Working directory is added via include_workdir
# Temp files (bash tool scripts, eval workdirs)
- /tmp
# Shell redirections (2>/dev/null)
- /dev/null
# SQLite database (default location is workdir, covered by include_workdir)
# Logs
- /var/log
landlock:
# best_effort: degrade gracefully on kernels without Landlock (< 5.13)
# Change to hard_requirement for production hardened deployments
compatibility: best_effort
# ---------------------------------------------------------------------------
# Process: privilege separation
# ---------------------------------------------------------------------------
process:
run_as_user: sandbox
run_as_group: sandbox
# ---------------------------------------------------------------------------
# Network: per-endpoint, per-binary allowlisting
# ---------------------------------------------------------------------------
# Default-deny. Only listed host:port pairs are reachable.
# Child processes (MCP servers, bash subcommands) inherit the network
# namespace — they cannot bypass the proxy.
network_policies:
# --- LLM API providers ---
openai_api:
name: openai-api
endpoints:
- host: api.openai.com
port: 443
binaries:
- path: /usr/bin/python3*
- path: /usr/local/bin/python3*
anthropic_api:
name: anthropic-api
endpoints:
- host: api.anthropic.com
port: 443
binaries:
- path: /usr/bin/python3*
- path: /usr/local/bin/python3*
# --- Web search fallback (Tavily) ---
tavily_api:
name: tavily-search
endpoints:
- host: api.tavily.com
port: 443
binaries:
- path: /usr/bin/python3*
- path: /usr/local/bin/python3*
# --- Skill discovery ---
skills_registry:
name: skills-registry
endpoints:
- host: skills.sh
port: 443
binaries:
- path: /usr/bin/python3*
- path: /usr/local/bin/python3*
github_api:
name: github-api
endpoints:
- host: api.github.com
port: 443
protocol: rest
tls: terminate
enforcement: enforce
access: read-only
- host: raw.githubusercontent.com
port: 443
binaries:
- path: /usr/bin/python3*
- path: /usr/local/bin/python3*
mcp_registry:
name: mcp-registry
endpoints:
- host: registry.modelcontextprotocol.io
port: 443
protocol: rest
tls: terminate
enforcement: enforce
access: read-only
binaries:
- path: /usr/bin/python3*
- path: /usr/local/bin/python3*
# --- OIDC SSO ---
# CUSTOMIZE: replace with your identity provider's hostname
# oidc_provider:
# name: oidc-provider
# endpoints:
# - host: login.example.com
# port: 443
# binaries:
# - path: /usr/bin/python3*
# - path: /usr/local/bin/python3*
# --- Discord (channel integration) ---
# Uncomment if using turnstone-channel with Discord adapter.
# discord:
# name: discord
# endpoints:
# - host: discord.com
# port: 443
# - host: gateway.discord.gg
# port: 443
# - host: cdn.discordapp.com
# port: 443
# binaries:
# - path: /usr/bin/python3*
# - path: /usr/local/bin/python3*
# --- web_fetch tool: curated domain allowlist ---
#
# This is the hard tradeoff. Turnstone's web_fetch tool lets the LLM
# fetch arbitrary public URLs. OpenShell cannot allow "all HTTPS" —
# every domain must be enumerated.
#
# Strategy: allowlist the domains your workloads actually need.
# The web_fetch tool will return a connection error for unlisted domains,
# which the LLM handles gracefully (it tells the user it can't reach
# that site).
#
# CUSTOMIZE: add domains your workstreams need to fetch from.
web_fetch_common:
name: web-fetch-common
endpoints:
# Documentation sites
- host: "**.readthedocs.io"
port: 443
- host: docs.python.org
port: 443
- host: "**.github.io"
port: 443
# Package registries (metadata lookups)
- host: pypi.org
port: 443
- host: www.npmjs.com
port: 443
# Stack Overflow / reference
- host: stackoverflow.com
port: 443
- host: "**.stackexchange.com"
port: 443
# Wikipedia
- host: "**.wikipedia.org"
port: 443
binaries:
- path: /usr/bin/python3*
- path: /usr/local/bin/python3*
# --- MCP HTTP servers ---
# CUSTOMIZE: add endpoints for any MCP servers using streamable-http
# transport. stdio-transport MCP servers need no network entry (they
# communicate via stdin/stdout pipes within the sandbox).
# mcp_http_servers:
# name: mcp-http
# endpoints:
# - host: mcp.internal.example.com
# port: 443
# binaries:
# - path: /usr/bin/python3*
# - path: /usr/local/bin/python3*
# --- Bash tool: curl/wget ---
# The bash tool can run curl/wget. These inherit the network namespace
# so they can only reach allowed endpoints. But they need binary entries
# to pass the proxy's identity check.
bash_network_tools:
name: bash-network-tools
endpoints:
# Mirrors web_fetch_common — curl/wget should have the same reach.
- host: "**.readthedocs.io"
port: 443
- host: docs.python.org
port: 443
- host: "**.github.io"
port: 443
- host: pypi.org
port: 443
- host: www.npmjs.com
port: 443
- host: stackoverflow.com
port: 443
- host: "**.stackexchange.com"
port: 443
- host: "**.wikipedia.org"
port: 443
binaries:
- path: /usr/bin/curl
- path: /usr/bin/wget
# --- Package installation ---
# pip install / uv add from the bash tool.
package_registries:
name: package-install
endpoints:
- host: pypi.org
port: 443
- host: files.pythonhosted.org
port: 443
- host: "**.pypi.org"
port: 443
binaries:
- path: /usr/bin/pip*
- path: /usr/local/bin/pip*
- path: /usr/bin/uv
- path: /usr/local/bin/uv
- path: /usr/bin/python3*
- path: /usr/local/bin/python3*
# --- Git operations ---
# read-only: clone, fetch, pull. No push (L7 enforcement).
git_operations:
name: git-read-only
endpoints:
- host: github.com
port: 443
protocol: rest
tls: terminate
enforcement: enforce
rules:
- allow:
method: GET
path: "/**/info/refs*"
- allow:
method: POST
path: "/**/git-upload-pack"
- host: gitlab.com
port: 443
protocol: rest
tls: terminate
enforcement: enforce
rules:
- allow:
method: GET
path: "/**/info/refs*"
- allow:
method: POST
path: "/**/git-upload-pack"
binaries:
- path: /usr/bin/git
@@ -22,8 +22,3 @@ output "rds_endpoint" {
description = "RDS PostgreSQL endpoint."
value = module.turnstone.rds_endpoint
}
output "redis_endpoint" {
description = "ElastiCache Redis endpoint."
value = module.turnstone.redis_endpoint
}
@@ -10,7 +10,7 @@ variable "vpc_id" {
}
variable "private_subnet_ids" {
description = "List of private subnet IDs for ECS tasks, RDS, and ElastiCache."
description = "List of private subnet IDs for ECS tasks and RDS."
type = list(string)
}
@@ -1,30 +0,0 @@
# ---------- ElastiCache Subnet Group ----------
resource "aws_elasticache_subnet_group" "this" {
name = "${var.name_prefix}-${var.environment}"
subnet_ids = var.private_subnet_ids
tags = local.common_tags
}
# ---------- ElastiCache Redis Replication Group ----------
resource "aws_elasticache_replication_group" "this" {
replication_group_id = "${var.name_prefix}-${var.environment}"
description = "Turnstone Redis for MQ and session state"
engine = "redis"
engine_version = "7.1"
node_type = var.redis_node_type
num_cache_clusters = 1
port = 6379
subnet_group_name = aws_elasticache_subnet_group.this.name
security_group_ids = [aws_security_group.redis.id]
at_rest_encryption_enabled = true
transit_encryption_enabled = true
automatic_failover_enabled = false
tags = local.common_tags
}
+1 -1
View File
@@ -40,8 +40,8 @@ resource "aws_iam_role_policy" "ecs_execution_secrets" {
[
aws_secretsmanager_secret.openai_api_key.arn,
aws_secretsmanager_secret.db_password.arn,
aws_secretsmanager_secret.jwt_secret.arn,
],
var.auth_token != "" ? [aws_secretsmanager_secret.auth_token[0].arn] : [],
)
},
]
+16 -72
View File
@@ -27,7 +27,6 @@ locals {
{ name = "TURNSTONE_ENV", value = var.environment },
{ name = "TURNSTONE_DB_BACKEND", value = "postgresql" },
{ name = "TURNSTONE_LLM_BASE_URL", value = var.llm_base_url },
{ name = "TURNSTONE_REDIS_URL", value = "redis://${aws_elasticache_replication_group.this.primary_endpoint_address}:6379/0" },
]
# Secrets pulled from Secrets Manager at container start.
@@ -42,20 +41,26 @@ locals {
},
]
auth_env = var.auth_token != "" ? [
{ name = "TURNSTONE_AUTH_ENABLED", value = "true" },
] : []
auth_secrets = var.auth_token != "" ? [
auth_secrets = [
{
name = "TURNSTONE_AUTH_TOKEN"
valueFrom = aws_secretsmanager_secret_version.auth_token[0].arn
name = "TURNSTONE_JWT_SECRET"
valueFrom = aws_secretsmanager_secret_version.jwt_secret.arn
},
] : []
]
}
# ---------- Secrets Manager ----------
resource "aws_secretsmanager_secret" "jwt_secret" {
name = "${var.name_prefix}-${var.environment}-jwt-secret"
tags = local.common_tags
}
resource "aws_secretsmanager_secret_version" "jwt_secret" {
secret_id = aws_secretsmanager_secret.jwt_secret.id
secret_string = var.jwt_secret
}
resource "aws_secretsmanager_secret" "openai_api_key" {
name = "${var.name_prefix}-${var.environment}-openai-api-key"
tags = local.common_tags
@@ -66,17 +71,7 @@ resource "aws_secretsmanager_secret_version" "openai_api_key" {
secret_string = var.openai_api_key
}
resource "aws_secretsmanager_secret" "auth_token" {
count = var.auth_token != "" ? 1 : 0
name = "${var.name_prefix}-${var.environment}-auth-token"
tags = local.common_tags
}
resource "aws_secretsmanager_secret_version" "auth_token" {
count = var.auth_token != "" ? 1 : 0
secret_id = aws_secretsmanager_secret.auth_token[0].id
secret_string = var.auth_token
}
resource "aws_secretsmanager_secret" "db_password" {
name = "${var.name_prefix}-${var.environment}-db-password"
@@ -141,7 +136,7 @@ resource "aws_ecs_task_definition" "server" {
{ containerPort = 8080, protocol = "tcp" },
]
environment = concat(local.common_env, local.auth_env)
environment = local.common_env
secrets = concat(local.common_secrets, local.auth_secrets)
logConfiguration = {
@@ -187,57 +182,6 @@ resource "aws_ecs_service" "server" {
depends_on = [aws_lb_target_group.server]
}
# ---------- Bridge Task Definition + Service ----------
resource "aws_ecs_task_definition" "bridge" {
family = "${var.name_prefix}-bridge"
requires_compatibilities = ["FARGATE"]
network_mode = "awsvpc"
cpu = var.bridge_cpu
memory = var.bridge_memory
execution_role_arn = aws_iam_role.ecs_execution.arn
task_role_arn = aws_iam_role.ecs_task.arn
tags = local.common_tags
container_definitions = jsonencode([
{
name = "bridge"
image = local.full_image
essential = true
command = ["turnstone-bridge"]
environment = concat(local.common_env, local.auth_env)
secrets = concat(local.common_secrets, local.auth_secrets)
logConfiguration = {
logDriver = "awslogs"
options = {
"awslogs-group" = aws_cloudwatch_log_group.this.name
"awslogs-region" = data.aws_region.current.name
"awslogs-stream-prefix" = "bridge"
}
}
},
])
}
resource "aws_ecs_service" "bridge" {
name = "${var.name_prefix}-bridge"
cluster = aws_ecs_cluster.this.id
task_definition = aws_ecs_task_definition.bridge.arn
desired_count = 1
launch_type = "FARGATE"
tags = local.common_tags
network_configuration {
subnets = var.private_subnet_ids
security_groups = [aws_security_group.ecs_tasks.id]
assign_public_ip = false
}
depends_on = [aws_ecs_service.server]
}
# ---------- Console Task Definition + Service ----------
resource "aws_ecs_task_definition" "console" {
@@ -261,7 +205,7 @@ resource "aws_ecs_task_definition" "console" {
{ containerPort = 8090, protocol = "tcp" },
]
environment = concat(local.common_env, local.auth_env)
environment = local.common_env
secrets = concat(local.common_secrets, local.auth_secrets)
logConfiguration = {
@@ -22,8 +22,3 @@ output "rds_endpoint" {
description = "Endpoint of the RDS PostgreSQL instance (host:port)."
value = aws_db_instance.this.endpoint
}
output "redis_endpoint" {
description = "Primary endpoint of the ElastiCache Redis replication group."
value = aws_elasticache_replication_group.this.primary_endpoint_address
}
@@ -112,22 +112,3 @@ resource "aws_vpc_security_group_ingress_rule" "rds_from_ecs" {
referenced_security_group_id = aws_security_group.ecs_tasks.id
tags = local.common_tags
}
# ---------- Redis Security Group ----------
resource "aws_security_group" "redis" {
name = "${var.name_prefix}-redis-${var.environment}"
description = "Allow Redis access from ECS tasks"
vpc_id = var.vpc_id
tags = local.common_tags
}
resource "aws_vpc_security_group_ingress_rule" "redis_from_ecs" {
security_group_id = aws_security_group.redis.id
description = "Redis from ECS tasks"
from_port = 6379
to_port = 6379
ip_protocol = "tcp"
referenced_security_group_id = aws_security_group.ecs_tasks.id
tags = local.common_tags
}
+3 -24
View File
@@ -6,7 +6,7 @@ variable "vpc_id" {
}
variable "private_subnet_ids" {
description = "List of private subnet IDs for ECS tasks, RDS, and ElastiCache."
description = "List of private subnet IDs for ECS tasks and RDS."
type = list(string)
}
@@ -50,14 +50,6 @@ variable "db_instance_class" {
default = "db.t4g.micro"
}
# --- ElastiCache ---
variable "redis_node_type" {
description = "ElastiCache node type for Redis."
type = string
default = "cache.t4g.micro"
}
# --- ECS Task Sizing ---
variable "server_cpu" {
@@ -72,18 +64,6 @@ variable "server_memory" {
default = 1024
}
variable "bridge_cpu" {
description = "CPU units for the bridge task."
type = number
default = 256
}
variable "bridge_memory" {
description = "Memory (MiB) for the bridge task."
type = number
default = 512
}
variable "console_cpu" {
description = "CPU units for the console task."
type = number
@@ -110,11 +90,10 @@ variable "name_prefix" {
default = "turnstone"
}
variable "auth_token" {
description = "Optional authentication token for the Turnstone API. Empty string disables auth."
variable "jwt_secret" {
description = "JWT signing secret for Turnstone auth (required, min 32 characters)."
type = string
sensitive = true
default = ""
}
variable "certificate_arn" {
+1279 -47
View File
File diff suppressed because it is too large Load Diff
+351 -159
View File
@@ -3,7 +3,7 @@
Turnstone is an AI orchestration platform with tool use, parallel workstreams, and persistent
memory. It connects to any OpenAI-compatible API (local vLLM, OpenAI, etc.) or
Anthropic's native Messages API via pluggable provider adapters, and gives the
model 14 built-in tools plus external tools via MCP (Model Context Protocol) for
model 19 built-in tools plus external tools via MCP (Model Context Protocol) for
reading, writing, searching, planning, and executing code.
The core design principle is a **UI-agnostic engine with pluggable frontends**.
@@ -18,11 +18,11 @@ plugs in.
|---------|--------|----------|---------|
| `turnstone` | `turnstone.cli` | `TerminalUI` | Interactive terminal REPL |
| `turnstone-server` | `turnstone.server` | `WebUI` | Browser-based chat (HTTP + SSE) |
| `turnstone-bridge` | `turnstone.mq.bridge` | Bridge | Message queue ↔ HTTP API bridge |
| `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.) via Redis MQ |
| `turnstone-admin` | `turnstone.core.admin_cli` | — | Offline user and API token management |
| `turnstone-channel` | `turnstone.channels.cli` | ChannelAdapter | Channel gateway (Discord, Slack, etc.) |
| `turnstone-admin` | `turnstone.admin` | — | Offline user and API token management |
| `turnstone-bootstrap` | `turnstone.bootstrap` | — | LLM-guided setup wizard |
---
@@ -37,14 +37,24 @@ 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)
mcp_client.py MCPClientManager — MCP server connections, tool discovery, async-sync bridge
mcp_client.py MCPClientManager — MCP server connections, tool discovery, dynamic refresh
tool_search.py Dynamic tool search — BM25 index, session-scoped tool visibility
watch.py WatchRunner daemon — periodic command polling, condition DSL, result dispatch
judge.py Intent validation — heuristic rules + LLM judge, advisory verdicts
model_registry.py ModelRegistry — named model configs, lazy client creation, fallback routing
memory.py Persistence facade (delegates to storage backend)
memory.py Persistence facade + structured memory API (delegates to storage backend)
config.py Config file loader (config.toml), apply_config(), warn_migrated_settings()
config_store.py ConfigStore — database-backed settings with in-memory cache, thread-safe get/set
settings_registry.py SettingDef catalog (~40 settings), validation, type coercion, serialization
storage/ Pluggable storage: StorageBackend protocol, SQLite + PostgreSQL
metrics.py Prometheus-compatible metrics collector (MetricsCollector)
healthcheck.py BackendHealthMonitor — periodic probe + circuit breaker
@@ -68,33 +78,31 @@ turnstone/
_base.py Shared httpx async client, auth, error handling
_sync.py Background event loop for sync wrappers
_types.py TurnResult + TurnstoneAPIError
mq/
protocol.py Inbound/outbound message dataclasses (JSON serialization)
broker.py Abstract MessageBroker protocol + RedisBroker
bridge.py Bridge service (queue ↔ turnstone-server HTTP API)
client.py TurnstoneClient library + TurnResult for MQ-based access
console/
collector.py ClusterCollector — aggregates state from all nodes via Redis + HTTP
scheduler.py TaskScheduler — background cron/at scheduler, dispatches via MQ
collector.py ClusterCollector — aggregates state from all nodes via SSE
scheduler.py TaskScheduler — background cron/at scheduler, dispatches via HTTP
server.py Cluster dashboard HTTP server + SSE + CLI entry point
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
_routing.py ChannelRouter — channel/thread ↔ workstream mapping via MQ
_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.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)
spinner.py Braille character spinner (daemon thread)
static/
index.html Single-page app shell (links to CSS and JS)
style.css Page-specific UI styles (dashboard layout, approval blocks)
app.js Page-specific client-side JavaScript (SSE, workstreams, markdown)
style.css Page-specific UI styles (dashboard, markdown elements, approval blocks)
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 14 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/`.
@@ -127,6 +135,7 @@ A user message flows through the system as follows:
| on_reasoning_token() / on_content_token()
| accumulate tool_calls from deltas
| track finish_reason
| _check_cancelled() per chunk (cooperative cancel)
v
finish_reason check:
+--- "length" --> warn, discard partial tool_calls
@@ -172,11 +181,13 @@ Phase 2: APPROVE (serial, blocking)
_emit_state("running")
Phase 3: EXECUTE (parallel)
_check_cancelled() <-- cancellation checkpoint before execution starts
if len(items) == 1:
run_one(items[0])
else:
ThreadPoolExecutor(max_workers=4).map(run_one, items)
Bash tool streams stdout line-by-line via ui.on_tool_output_chunk(call_id, line)
(cancel_event also checked per line — kills process group on cancel)
Final output (stdout + stderr) delivered via ui.on_tool_result(call_id, name, output)
call_id links tool_info items → streaming chunks → final result
For plan tool: post-execution gate via ui.on_plan_review()
@@ -207,6 +218,11 @@ The engine emits state changes via `_emit_state()` which calls
"idle" ---> no more tool calls, turn complete
|
(or "error" ---> exception or KeyboardInterrupt)
cancel() may be called from any state. It sets a cooperative flag
checked at each streaming chunk, before tool execution, and inside
bash commands. The session transitions to "idle" with partial
content preserved, emitting on_info("[Generation cancelled]").
```
---
@@ -226,7 +242,7 @@ class SessionUI(Protocol):
def on_content_token(self, text: str) -> None: ...
def on_stream_end(self) -> None: ...
def approve_tools(self, items: list[dict]) -> tuple[bool, str | None]: ...
def on_tool_result(self, call_id: str, name: str, output: str) -> None: ...
def on_tool_result(self, call_id: str, name: str, output: str, *, is_error: bool = False) -> None: ...
def on_tool_output_chunk(self, call_id: str, chunk: str) -> None: ...
def on_status(self, usage: dict, context_window: int, effort: str) -> None: ...
def on_plan_review(self, content: str) -> str: ...
@@ -243,7 +259,7 @@ class SessionUI(Protocol):
| Class | Module | Notes |
|-------|--------|-------|
| `TerminalUI` | `turnstone.cli` | ANSI colors, `MarkdownRenderer`, `Spinner`, readline-based `input()` for approval |
| `WebUI` | `turnstone.server` | SSE event queue per workstream, `threading.Event` for blocking on approval/plan |
| `WebUI` | `turnstone.server` | SSE event queue per workstream + global broadcast, `threading.Event` for blocking on approval/plan. `on_state_change` sends to both per-workstream and global SSE (the browser UI uses per-workstream `state_change` events to manage busy/idle transitions; `stream_end` only finalizes markdown rendering). |
| `NullUI` | `turnstone.eval` | Discards all output; `approve_tools` always returns `(True, None)` |
### WorkstreamTerminalUI
@@ -337,7 +353,7 @@ remove the tab immediately. Controlled by `--workstream-idle-timeout` (default:
**Workstream eviction at capacity:** When `WorkstreamManager.create()` would
exceed `max_workstreams` (configurable via `[server].max_workstreams`, default
10), the oldest IDLE workstream is automatically evicted to make room. The
50), the oldest IDLE workstream is automatically evicted to make room. The
`turnstone_workstreams_evicted_total` counter is incremented on each eviction.
If no IDLE workstream is available the create request fails as before.
@@ -359,13 +375,20 @@ non-idle background workstreams above the input prompt.
### Web Workstreams
- **Tab bar**: Each workstream renders as a tab with a colored state indicator
(CSS `@keyframes pulse` animation per state).
- **Per-tab SSE**: `connectContentSSE(wsId)` opens
`/v1/api/events?ws_id=<id>` for the active tab's event stream.
(CSS `@keyframes pulse` animation per state). Clicking a tab switches the
focused pane's workstream (or focuses an existing pane showing that ws).
- **Split panes**: The UI supports tiling multiple workstreams side-by-side or
stacked via a binary layout tree. Each `Pane` instance encapsulates its own
SSE connection, message area, input, and state (busy, approval, streaming).
Split via right-click context menu, pane header buttons, or keyboard
(`Ctrl+\`, `Ctrl+Shift+\`). Max 6 panes; no duplicate workstreams across panes.
Layout persisted to `localStorage`.
- **Per-pane SSE**: `Pane.connectSSE(wsId)` opens
`/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 without switching.
- **New tab / close**: POST `/v1/api/workstreams/new`, POST `/v1/api/workstreams/close`.
indicators and pane headers without switching.
- **New tab / close**: POST `/v1/api/workstreams/new`, POST `/v1/api/workstreams/{ws_id}/close`.
### Thread Safety
@@ -425,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
### 14 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` -- retrieve stored memories
- `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`)
@@ -440,14 +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 (persistent key-value store)**:
- `remember` -- save a fact
- `forget` -- delete a fact
**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
@@ -466,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.
@@ -493,23 +524,74 @@ independently, then returns the final content as the tool result.
and exposes their tools alongside built-in tools. The MCP SDK is fully async; turnstone
bridges this with a background asyncio event loop in a daemon thread.
**Configuration sources:** MCP servers can be defined in config files (TOML/JSON)
or in the database via the admin UI. Database-backed definitions are managed
through the console admin panel's MCP Servers tab and stored in the
`mcp_servers` table. On startup, `load_mcp_config(storage=)` uses
first-match-wins priority: DB rows (if any enabled) take precedence over
config files. The console can trigger a cluster-wide reload (`POST
/_internal/mcp-reload`) that causes each node to call `reconcile_sync()`,
which diffs the running MCP connections against the current DB state and
adds, removes, or reconnects servers as needed.
**Lifecycle:**
1. `create_mcp_client()` reads server configs from TOML or JSON
1. `create_mcp_client()` reads server configs from TOML/JSON and database
2. `MCPClientManager.start()` launches the background event loop thread
3. `_connect_all()` connects to each server (stdio subprocess or HTTP), runs
`initialize()` + `list_tools()`, converts schemas to OpenAI format
4. `ChatSession.__init__` receives the manager and builds `self._tools` (built-in + MCP)
`initialize()` + `list_tools()`, converts schemas to OpenAI format, detects
`tools.listChanged` capability for push notification support
4. `ChatSession.__init__` receives the manager, builds `self._tools` (built-in + MCP),
and registers a listener callback for tool-change notifications
5. `_prepare_tool()` routes MCP tools to `_prepare_mcp_tool()` / `_exec_mcp_tool()`
6. `_exec_mcp_tool()` calls `call_tool_sync()` which dispatches to the async loop
via `asyncio.run_coroutine_threadsafe()`
**Tool refresh:** Three mechanisms keep tools up-to-date without restart:
- **Push:** Servers declaring `tools.listChanged` send `ToolListChangedNotification`;
the registered `message_handler` triggers immediate single-server refresh.
- **Periodic:** Servers without push support are polled on a staggered interval
(default 4 h, configurable via `[mcp] refresh_interval` or `--mcp-refresh-interval`).
- **Manual:** `/mcp refresh [server]` calls `refresh_sync()` for on-demand refresh
(also attempts reconnection for disconnected servers).
When tools change, `_rebuild_tools()` creates new `_tools`/`_tool_map` objects
(copy-on-write for thread safety) and notifies listener callbacks. Each `ChatSession`
rebuilds its merged tool lists and reconstructs `ToolSearchManager` (preserving
expanded tools).
**Tool naming:** `mcp__{server}__{tool}` — double underscore delimiter, validated
at connection time (server names with `__` are rejected).
**Error isolation:** Per-server connection failures are caught and logged; other
servers still connect. Tool execution errors return error strings to the LLM
**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.
**Registry discovery:** The console admin panel provides a registry discovery
surface backed by the official MCP Registry (registry.modelcontextprotocol.io).
`MCPRegistryClient` (`turnstone/core/mcp_registry.py`) is a standalone httpx
async client that queries the registry's v0.1 API for server discovery. Search
results are annotated with installed status by cross-referencing the
`mcp_servers` table. Installation creates a DB row with `registry_name`,
`registry_version`, and `registry_meta` columns (migration 019), then triggers
cluster-wide node reload via `_notify_nodes_mcp_reload()`. The registry URL is
configurable via the `mcp.registry_url` setting for enterprise/private
registries.
### Provider Adapter Layer
> See also: [Core Engine Classes diagram](diagrams/png/03-core-engine-classes.png)
@@ -526,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:**
@@ -544,29 +627,47 @@ LLMProvider (protocol)
|------|--------|
| `StreamChunk` | `content_delta`, `reasoning_delta`, `tool_call_deltas`, `info_delta`, `usage`, `finish_reason` |
| `CompletionResult` | `content`, `tool_calls`, `finish_reason`, `usage` |
| `ModelCapabilities` | `context_window`, `max_output_tokens`, `supports_temperature`, `token_param`, `thinking_mode`, `supports_effort`, `supports_web_search` |
| `UsageInfo` | `prompt_tokens`, `completion_tokens`, `total_tokens` |
| `ModelCapabilities` | `context_window`, `max_output_tokens`, `supports_temperature`, `token_param`, `thinking_mode`, `supports_effort`, `supports_web_search`, `supports_tool_search`, `supports_vision` |
| `UsageInfo` | `prompt_tokens`, `completion_tokens`, `total_tokens`, `cache_creation_tokens`, `cache_read_tokens` |
**OpenAIProvider** (`_openai.py`): passes messages through unchanged (they are
already in OpenAI format). Model capability lookup table covers
GPT-5/5.1/5.2, O-series, and search models (`gpt-5-search-api`).
already in OpenAI format), including multi-part content blocks (text + images)
in tool results. Model capability lookup table covers GPT-5/5.1/5.2/5.3/5.4,
O-series, and search models (`gpt-5-search-api`) — all with `supports_vision`.
For search models, injects `web_search_options` and removes the `web_search`
function tool (the model always searches). Citations from `url_citation`
annotations are formatted as footnotes. Unknown models (local servers) get
permissive defaults and use Tavily for web search.
annotations are formatted as footnotes. Extended prompt cache retention
(`prompt_cache_retention: "24h"`) is enabled for GPT-5.x models at no
additional cost. Cached token counts are extracted from
`usage.prompt_tokens_details.cached_tokens`. Unknown models (local servers) get
permissive defaults with `supports_vision=False` and use Tavily for web search.
**AnthropicProvider** (`_anthropic.py`): converts OpenAI-format messages to
Anthropic content blocks, maps `system`/`developer` roles to the `system`
parameter, groups consecutive `tool` result messages into user-role content
blocks, and translates tool schemas from OpenAI function-calling format to
blocks (converting `image_url` parts to Anthropic's `image` source format),
and translates tool schemas from OpenAI function-calling format to
Anthropic's `input_schema` format. Supports both manual and adaptive thinking
modes, with effort parameter support for models like Claude Opus 4.6 and
Sonnet 4.6. Replaces the `web_search` function tool with Anthropic's native
`web_search_20250305` server-side tool — Claude decides when to search, the
API executes it, and results stream back as `server_tool_use` /
`web_search_tool_result` content blocks (emitted as `info_delta` for UI
display). The `anthropic` SDK is imported lazily so it remains an optional
dependency (`pip install turnstone[anthropic]`).
display). Automatic prompt caching is enabled via top-level `cache_control:
{"type": "ephemeral"}` — the API places the cache breakpoint on the last
cacheable block and advances it as conversations grow (90% input cost
reduction on cache hits, 1.25x write on first turn). Cache metrics
(`cache_creation_input_tokens`, `cache_read_input_tokens`) are extracted from
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,
@@ -596,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"]
@@ -603,11 +708,50 @@ 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):
```toml
[models.qwen-vl]
base_url = "http://localhost:8000/v1"
model = "qwen-3.5-vl"
[models.qwen-vl.capabilities]
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
@@ -616,15 +760,16 @@ Each `[models.*]` entry produces a `ModelConfig` with a `provider` field
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
sub-agents, allowing a cheaper model for autonomous loops
**Per-workstream selection:** `POST /v1/api/workstreams/new` accepts an optional
`"model"` field. The bridge `CreateWorkstreamMessage` carries the same field
through the MQ protocol.
`"model"` field, along with `skill` (skill name)
which can override the model before workstream creation.
### Tool Output Truncation
@@ -739,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) |
@@ -753,10 +898,16 @@ and are the single source of truth for both backends and Alembic migrations.
backend = "sqlite" # "sqlite" | "postgresql"
path = ".turnstone.db" # SQLite file path
url = "" # PostgreSQL connection URL
pool_size = 5 # PostgreSQL connection pool size
pool_size = 2 # PostgreSQL connection pool size (per process)
```
Environment variables: `TURNSTONE_DB_BACKEND`, `TURNSTONE_DB_URL`, `TURNSTONE_DB_PATH`.
Environment variables: `TURNSTONE_DB_BACKEND`, `TURNSTONE_DB_URL`, `TURNSTONE_DB_PATH`,
`TURNSTONE_DB_POOL_SIZE`.
The default pool is intentionally small (2 base + 3 overflow = 5 per process)
because all database operations are short-burst queries that hold connections for
milliseconds. For clusters with many nodes sharing a PostgreSQL instance, use
[PgBouncer](pgbouncer.md) in transaction pooling mode.
### Persistence and Resume
@@ -878,6 +1029,9 @@ warns if the summary was truncated.
seeded with `history.replaceState({turnstone: 'dashboard'})` on load. The
`popstate` listener restores the correct tab or shows the dashboard,
guarded by `_historyNavigation = true` to prevent re-entrant pushState.
- **Pane focus**: `mousedown` and `focusin` events on pane containers update
`focusedPaneId`. Approval shortcuts (y/n/a) apply to the focused pane.
`Ctrl+Alt+Arrow` cycles focus between panes.
### Eval Resilience
@@ -932,13 +1086,10 @@ limits using a token-bucket algorithm. Each IP gets a `TokenBucket` with
Turnstone supports three authentication mechanisms, unified behind an
`AuthResult` dataclass that carries `user_id`, `scopes`, and `token_source`:
1. **Config-file tokens**static secrets in `config.toml` `[[auth.tokens]]`
or the `TURNSTONE_AUTH_TOKEN` env var. Validated in-memory via
`hmac.compare_digest`. Map to scopes through their role (`read` or `full`).
2. **API tokens** — database-backed, prefixed `ts_`, stored as SHA-256 hashes
1. **API tokens**database-backed, prefixed `ts_`, stored as SHA-256 hashes
in the `api_tokens` table. Can be exchanged for JWTs via
`POST /v1/api/auth/login`.
3. **JWTs** — short-lived HMAC-SHA256 session tokens (default 24h) issued after
2. **JWTs** — short-lived HMAC-SHA256 session tokens (default 24h) issued after
successful credential validation. Contain `sub` (user_id), `scopes`, and
`src` (origin) in claims.
@@ -949,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
@@ -962,9 +1113,8 @@ Three hierarchical scopes control endpoint access:
2. **Token extraction**`Authorization: Bearer <token>` header first, then
`turnstone_auth` cookie as fallback.
3. **Token type detection** — dots in the token indicate JWT; `ts_` prefix
indicates API token; otherwise config-file token.
4. **Validation** — JWT signature check, API token hash lookup in storage, or
config-token hmac comparison.
indicates API token.
4. **Validation** — JWT signature check or API token hash lookup in storage.
5. **Scope check**`required_scope(method, path)` determines the minimum
scope; the request is rejected with 403 if the token lacks it.
6. **Context propagation** — on success, `ctx_user_id` is set so structured
@@ -975,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** (Users and Tokens tabs) for managing
credentials 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).
@@ -1047,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
@@ -1082,7 +1233,7 @@ context manager handles startup/shutdown (health monitor, MCP client,
registry).
Each workstream's `WebUI` has:
- `_event_queue` (per-workstream SSE events, `queue.Queue`)
- `_listeners` (per-client SSE queues, fan-out on `_enqueue()`)
- `_approval_event` / `_plan_event` (`threading.Event` for blocking)
- `_global_queue` (class variable, shared, for state broadcasts)
@@ -1114,81 +1265,44 @@ calls `_fg_event.wait()`, which blocks the worker thread until the user
switches to that workstream. The `_bg_attention_notify` callback writes a
bell + status line to stderr to alert the user.
### Message Queue Bridge
```
Main thread Global SSE thread Per-WS SSE threads (×N)
+------------------+ +------------------+ +-------------------+
| Inbound loop | | GET /events/glob | | GET /events?ws_id |
| BLPOP on Redis | | Parse SSE via | | Parse SSE via |
| | | httpx-sse | | httpx-sse |
| Dispatch to | | Forward state | | Forward content, |
| handler | | changes | | tool results |
| POST to server | | Detect turn | | Handle approval |
| Publish ACK | | completion | | forwarding |
+------------------+ +------------------+ +-------------------+
| | |
+-- Redis inbound queue +-- Redis pub/sub +-- Redis pub/sub
(RPUSH/BLPOP) (PUBLISH) (PUBLISH)
+ response queue
(BLPOP on
approval)
```
**Approval flow:** When a per-WS SSE thread receives an `approve_request`, it checks
the workstream's `auto_approve_tools` set. If all requested tools are in the set, the
bridge auto-approves via `POST /v1/api/approve`. Otherwise, it publishes an
`ApprovalRequestEvent` to the outbound channel with a `request_id`, then blocks on
`BLPOP` of a Redis response queue (`turnstone:resp:{request_id}`) until the client pushes
a response or the approval timeout (default 3600s / 1 hour) expires.
**Completion detection:** The bridge tracks which `correlation_id` maps to which
`ws_id` for active sends. When the global SSE reports `ws_state → idle` for a tracked
workstream, the bridge emits a synthetic `TurnCompleteEvent` with the correlation ID.
**Multi-node routing:** Each bridge retrieves its `node_id` from the server's
`/health` endpoint on startup (with exponential backoff retry). The server
generates the `node_id` (`{hostname}_{4hex}`) and is the sole authority for
node identity. The bridge BLPOPs
from both `turnstone:inbound:{node_id}` (directed, priority) and `turnstone:inbound` (shared).
Messages with `target_node` set are pushed to the target's per-node queue. Messages
for existing workstreams are auto-routed via `turnstone:ws:{ws_id}` ownership keys in Redis.
If a bridge picks up a shared-queue message for a workstream owned by another node, it
re-routes to that node's queue (1 extra hop). Bridges publish heartbeats to
`turnstone:node:{node_id}` with configurable TTL for node discovery.
### Cluster Console
```
Monitoring (3 daemon threads) Control + Proxy (async Starlette)
Monitoring (2 daemon threads) Control + Proxy (async Starlette)
+------------------+ +----------------------------+
| Event subscriber | | POST /v1/api/cluster/ |
| SUBSCRIBE on | | workstreams/new |
| events:cluster | | → LPUSH to Redis |
+------------------+ | inbound:{node_id} |
| Node discovery | +----------------------------+
| SCAN node:* keys | | GET /node/{node_id}/ |
| every 15 seconds | | → httpx.AsyncClient |
+------------------+ | proxy to server_url |
| Poll loop | | GET /node/{id}/v1/api/events |
| GET /v1/api/dash | | → SSE stream proxy |
| GET /health | | POST /node/{id}/v1/api/send |
| ThreadPoolExec | | → forwarded to server |
| Node discovery | | POST /v1/api/cluster/ |
| Service registry | | workstreams/new |
| every 60 seconds | | → POST to target server |
+------------------+ +----------------------------+
| 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/workstreams/{ws_id}/events |
| snapshot+deltas | | → SSE stream proxy |
+------------------+ | POST /node/{id}/v1/api/workstreams/{ws_id}/send |
| → forwarded to server |
+----------------------------+
```
The console HTTP layer is a Starlette/ASGI app served by uvicorn. The SSE
endpoint uses `EventSourceResponse` with the same listener queue pattern as
the main server. `ClusterCollector`'s background threads (event subscriber,
node discovery, poll loop) use sync Redis clients and `ThreadPoolExecutor`
for parallel HTTP polling.
the main server. `ClusterCollector` runs two daemon threads: a discovery loop
that queries the service registry every 60 seconds, and an SSE manager that
runs a single asyncio event loop multiplexing persistent SSE connections to
all nodes via `GET /v1/api/events/global`. Each node delivers a full snapshot
on connect followed by real-time delta events — state changes, health
transitions, and aggregate metrics arrive sub-second instead of on a 15-second
poll cycle.
The console has two write-path capabilities:
1. **Workstream creation**pushes `CreateWorkstreamMessage` to Redis inbound
queues targeting specific nodes. The bridge on each node picks up the message
and creates the workstream on the local server. Auto-selects the node with
the most available capacity if no target is specified.
1. **Workstream creation**sends HTTP requests to target server nodes
to create workstreams. Auto-selects the node with
the most available capacity if no target is specified. When a `skill`
field is present, the server resolves the skill BEFORE `mgr.create()`
(applying the model override to the creation request) and snapshot-applies
remaining settings (auto-approve, token budget, temperature, etc.) to the
workstream config AFTER creation.
2. **Reverse proxy** — serves each node's server UI through the console port at
`/node/{node_id}/`. Uses `httpx.AsyncClient` to proxy HTTP and SSE traffic.
@@ -1248,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 the MQ package so SDK consumers don't need the `redis` dependency.
**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.
@@ -1271,35 +1386,112 @@ with TurnstoneServer("http://localhost:8080", token="tok_xxx") as client:
> See also: [Channel Integrations guide](channels.md)
The `turnstone-channel` gateway bridges external messaging platforms
(Discord, Slack, Teams) to the turnstone cluster via Redis MQ. Each
The `turnstone-channel` gateway connects external messaging platforms
(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 MQ messages.
between platform-native events and turnstone server API calls.
The `ChannelRouter` manages bidirectional routing: it maps platform
channel/thread IDs to turnstone workstream IDs, handles workstream
creation and stale-route recovery, and resolves platform users to
turnstone identities via the `channel_users` table. When an evicted
workstream is reactivated, the router uses atomic resume via the
`resume_ws` field on `CreateWorkstreamMessage` — the server resumes
`resume_ws` field on the workstream creation request — the server resumes
the old workstream's conversation during creation in a single HTTP
request, eliminating ordering fragility. The bridge emits a
`WorkstreamResumedEvent` to confirm success.
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.
### Notification Subsystem
The `notify` tool enables the LLM to send notifications to users or
channels without going through MQ. The server calls the channel gateway
channels directly. The server calls the channel gateway
directly over HTTP for lower latency: `_exec_notify()` queries the
`services` database table for healthy channel gateways (heartbeat within
120 seconds), authenticates with a service JWT (`aud: turnstone-channel`),
and POSTs to `POST /v1/api/notify` on the first healthy gateway. The
gateway validates the JWT, resolves the target (username lookup via
payload includes the originating `ws_id` for reply routing. The gateway
validates the JWT, resolves the target (username lookup via
`channel_users` or direct `channel_type`+`channel_id`), and delegates to
the appropriate `ChannelAdapter.send()`. Delivery 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).
`ChannelAdapter.send_notification()` which sends the message and tracks
the outgoing message ID → `(ws_id, target_user_id)` mapping. Delivery
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
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.
---
## Governance
> See also: [Governance documentation](governance.md) | [Governance Architecture diagram](diagrams/19-governance-architecture.puml)
Turnstone governance extends the Phase 1 auth system with role-based access
control (RBAC), tool execution policies, skills, usage tracking,
and audit logging. The permission model has two layers: legacy scopes
(`read`, `write`, `approve`) checked by `AuthMiddleware`, and 15 granular
permissions checked per-endpoint by `require_permission()`. Three built-in
roles (admin, operator, viewer) are seeded by migration 008; custom roles
can be created with any permission subset. JWTs carry both `scopes` and
`permissions` claims for backward compatibility.
Tool policies use glob pattern matching (`fnmatch`) with priority-ordered
first-match-wins evaluation to control tool execution (allow/deny/ask).
Skills provide reusable system messages with `{{variable}}` substitution
plus session configuration (model, temperature, auto-approve, token budget,
etc.). Usage events are recorded per-LLM-request for token accounting.
An append-only audit log captures all admin mutations.
Skills are snapshot-applied once at workstream creation — not a live binding.
The `prompt_templates` table (which stores skills) supports auto-versioning,
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 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.
## Intent Validation
> See also: [Intent Validation guide](judge.md) | [Judge Architecture diagram](diagrams/png/22-judge-architecture.png)
Intent validation provides advisory risk assessments for tool calls that
require human approval. The system runs a two-tier evaluation pipeline
implemented in `turnstone/core/judge.py`:
1. **Heuristic tier** (synchronous, sub-millisecond) -- A priority-ordered
rule table using fnmatch tool patterns and regex argument patterns. Four
severity levels: critical (deny), high (review), medium (review), low
(approve). First match wins. The heuristic verdict is attached to the
`approve_request` SSE event immediately.
2. **LLM judge tier** (asynchronous, daemon thread) -- A multi-turn evaluation
where the judge LLM receives conversation context and tool call details,
optionally uses `read_file`/`list_directory` to gather evidence (with
security-hardened path blocking), and produces a structured JSON verdict.
If the LLM verdict has higher confidence than the heuristic, it replaces
it via an `intent_verdict` SSE event.
The judge is session-scoped (`IntentJudge`), lazy-initialized on first
approval, and configured via the `[judge]` config section or `--judge` CLI
flags. By default it uses self-consistency (same model), but supports
cross-model and cross-provider configurations. Sub-agents (plan, task)
are exempt. All verdicts are persisted to the `intent_verdicts` table
(migration 012) with the user's final decision, enabling future calibration.
The console exposes `GET /v1/api/admin/verdicts` for audit queries
(requires `admin.judge` permission).
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
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.
+153 -51
View File
@@ -1,43 +1,44 @@
# Channel Integrations
The `turnstone-channel` gateway connects external messaging platforms to
turnstone workstreams via Redis MQ. Each platform adapter translates
turnstone workstreams via direct HTTP to the server (single-node) or the
console routing proxy (multi-node). Each platform adapter translates
platform-native events (messages, button clicks, slash commands) into
turnstone MQ messages, and renders workstream output back into the
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
Redis MQ
|
v
turnstone-bridge ──> turnstone-server
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()`, `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 MQ, stale route detection, and user identity resolution.
- **AsyncRedisBroker** (`turnstone/mq/async_broker.py`) — async Redis
client compatible with discord.py's event loop. Used by the router for
pub/sub and queue operations.
creation via HTTP, stale route detection, and user identity resolution.
- **channel_users table** — maps `(channel_type, channel_user_id)` to a
turnstone `user_id`. Messages from unlinked users are silently dropped.
- **channel_routes table** — persistent channel-to-workstream mappings.
@@ -84,8 +85,7 @@ TURNSTONE_DISCORD_GUILD=123456789 # optional, restrict to one guild
turnstone-channel \
--discord-token "your-bot-token" \
--discord-guild 123456789 \
--redis-host localhost \
--redis-port 6379
--server-url http://localhost:8080
```
**Docker Compose** (production profile):
@@ -124,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
@@ -138,7 +200,7 @@ An admin can also force-link or unlink users via the console admin panel
thread auto-creates a new workstream and atomically resumes the
previous workstream via the `resume_ws` field on
`CreateWorkstreamMessage`. The server resumes the workstream during
creation (same HTTP request), and the bridge emits a
creation (same HTTP request), and the server emits a
`WorkstreamResumedEvent` back to the channel. The thread receives a
*"Resumed: {name} ({count} messages restored)"* confirmation.
@@ -160,8 +222,7 @@ an orange embed with:
- Tool name and argument preview
- **Approve** (green), **Reject** (red), **Always Approve** (gray) buttons
- Only linked users can interact with approval buttons
- The approval decision is forwarded through MQ to the bridge, which
relays it to the server
- The approval decision is forwarded to the server via HTTP
Buttons use static `custom_id` values so they survive bot restarts.
Correlation data (`ws_id`, `correlation_id`) is stored in the embed footer.
@@ -181,7 +242,7 @@ Plan review requests are displayed as a blue embed with:
- **Approve Plan** (green) button — approves the plan with empty feedback
- **Request Changes** (gray) button — opens a modal for feedback text
(up to 2000 characters)
- Feedback is forwarded through MQ as a `PlanFeedbackMessage`
- Feedback is forwarded to the server via HTTP
---
@@ -189,21 +250,25 @@ 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 |
| `--redis-host` | `REDIS_HOST` | `localhost` | Redis host |
| `--redis-port` | — | `6379` | Redis port |
| `--redis-password` | `REDIS_PASSWORD` | — | Redis password |
| `--redis-db` | — | `0` | Redis DB number |
| `--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 |
| `--auto-approve` | — | `false` | Auto-approve ALL tool calls (skips approval buttons entirely) |
| `--http-host` | — | `127.0.0.1` | HTTP server bind address for notify endpoint |
| `--http-port` | `TURNSTONE_CHANNEL_PORT` | `8091` | HTTP server port |
| `--auth-token` | `TURNSTONE_CHANNEL_AUTH_TOKEN` | — | Static auth token for `/v1/api/notify` (alternative to JWT) |
| `--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
@@ -232,13 +297,13 @@ See [Security: Database Schema](security.md#database-schema) for the
3. **Eviction** — the server evicts an idle workstream for capacity. The
route is preserved and the thread stays open.
4. **Reactivation** — the next message in the thread detects the stale
route (no MQ owner) and creates a new workstream with the old `ws_id`
as `resume_ws` on the `CreateWorkstreamMessage`. The server resumes
route and creates a new workstream with the old `ws_id`
as `resume_ws` on the creation request. The server resumes
the workstream during creation (no separate command or reverse lookup
needed). The bridge emits a `WorkstreamResumedEvent` to the channel, and
needed). The channel receives a `WorkstreamResumedEvent`, and
the thread displays *"Resumed: {name} ({count} messages restored)"*.
If the old workstream was pruned, a fresh one starts with no error.
5. **Close**`/close` command closes the workstream via MQ, deletes the
5. **Close**`/close` command closes the workstream via HTTP, deletes the
route, unsubscribes from events, and archives the Discord thread.
---
@@ -257,27 +322,57 @@ 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.
### Delivery Flow
Notifications bypass MQ for lower latency. The server calls the channel
Notifications use direct HTTP for low latency. The server calls the channel
gateway directly over HTTP:
1. The LLM calls the `notify` tool with a message and target
2. `_exec_notify()` queries the `services` table for healthy channel
gateways (heartbeat within the last 120 seconds)
3. The server mints a service JWT (`aud: turnstone-channel`) via
`ServiceTokenManager` and POSTs to the first healthy gateway
`ServiceTokenManager` and POSTs to the first healthy gateway. The
payload includes the originating `ws_id` for reply routing.
4. The gateway validates the JWT, resolves the target, and calls
`adapter.send()` on the appropriate platform adapter
`adapter.send_notification()` which sends the message and tracks
the outgoing message ID for reply routing
5. On failure, the server tries the next gateway. If all fail, it
retries up to 2 more times (delays: 1s, 3s), re-querying the
service registry on each attempt
### Bidirectional Replies
Notifications support multi-turn DM conversations. When a user replies
to a notification DM:
1. The bot looks up the originating `ws_id` from the tracked message ID
(`_notify_ws_map`)
2. Verifies the replying user matches the original notification
recipient (defence in depth — Discord DMs are already private)
3. Routes the reply to the workstream via `router.send_message()`
4. Registers the DM channel for response forwarding
(`_notify_reply_channels`)
5. When the workstream responds (`TurnCompleteEvent`), the response is
forwarded to the DM
6. The response message is itself tracked, so the user can reply again
for another turn
This enables scenarios like an oncall engineer responding to a CI/CD
failure notification from their phone before opening a laptop.
**Limits:**
- Tracking map capped at 100 entries (FIFO eviction of oldest)
- Entries cleaned up on workstream close/unsubscribe
- Replying to an expired notification sends
*"This notification is no longer active."*
- DM reply content capped at 4096 characters
### Service Registry
The channel gateway registers itself in the `services` database table
@@ -298,11 +393,11 @@ The `services` table schema:
### Security
- **Authentication** — the gateway's `POST /v1/api/notify` endpoint
requires authentication. Configure either `TURNSTONE_JWT_SECRET`
(the server mints JWTs with `aud: turnstone-channel` automatically)
or a static token via `--auth-token`. If neither is set, the
gateway fails closed and rejects all requests with 401. Server JWTs
(`aud: turnstone-server`) are rejected.
requires authentication. Configure `TURNSTONE_JWT_SECRET` so the
server can mint JWTs with `aud: turnstone-channel` automatically.
If the secret is not set, the gateway fails closed and rejects all
requests with 401. Server JWTs (`aud: turnstone-server`) are
rejected.
- **Rate limit** — maximum 5 notifications per turn. The counter only
increments on successful delivery, so failures don't consume the
budget.
@@ -328,12 +423,19 @@ class ChannelAdapter(Protocol):
async def start(self) -> None: ...
async def stop(self) -> None: ...
async def send(self, channel_id: str, content: 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: ...
async def send_notification(self, channel_id: str, content: str, ws_id: str) -> str: ...
```
`send_notification()` is like `send()` but associates the outgoing
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
+101 -67
View File
@@ -1,16 +1,16 @@
# Cluster Dashboard (turnstone-console)
`turnstone-console` is a cluster management service that provides cluster-wide visibility and control across all turnstone nodes. It connects to the shared Redis broker, discovers nodes via heartbeat keys, polls each node's HTTP API for workstream data, and subscribes to a cluster event channel for real-time state changes.
`turnstone-console` is a cluster management service that provides cluster-wide visibility and control across all turnstone nodes. It discovers nodes via the `services` database table and subscribes to each node's SSE event stream for real-time workstream, health, and metric updates.
The console also supports **workstream creation** (dispatched via MQ to target nodes) and a **reverse proxy** that serves each node's server UI through the console port — so users only need network access to the console, not to individual server nodes.
The console also supports **workstream creation** (dispatched via HTTP proxy to target nodes) and a **reverse proxy** that serves each node's server UI through the console port — so users only need network access to the console, not to individual server nodes.
## Architecture
> See also: [Console Data Flow diagram](diagrams/png/11-console-data-flow.png)
```
┌── Redis ←── turnstone-bridge ── turnstone-server
(MQ) (per node) (per node)
┌── services table ── turnstone-server
(node registry) (per node)
turnstone-console ──────┤
(one instance) │
└── turnstone-server (direct HTTP proxy)
@@ -21,45 +21,30 @@ turnstone-console ──────┤
Data flows in two directions:
- **Inbound (monitoring):** Bridges publish state changes to `{prefix}:events:cluster` on Redis pub/sub. The console subscribes for real-time updates and periodically polls each node's `GET /v1/api/dashboard` for full workstream snapshots.
- **Outbound (control):** The console pushes `CreateWorkstreamMessage` to Redis inbound queues targeting specific nodes. Bridges pick up these messages and create workstreams on their local servers.
- **Inbound (monitoring):** The console discovers nodes via the `services` database table (nodes register on startup and send periodic heartbeats). It opens a persistent SSE connection to each node's `GET /v1/api/events/global` endpoint, receiving a full snapshot on connect followed by real-time delta events (state changes, health transitions, aggregate metrics).
- **Outbound (control):** The console proxies workstream creation requests to target nodes via HTTP.
- **Proxy (pass-through):** The console reverse-proxies each node's server UI at `/node/{node_id}/`, forwarding HTTP and SSE traffic so the browser never contacts server nodes directly.
### Data Sources
| Source | Method | Direction | Data |
|--------|--------|-----------|------|
| Redis heartbeats | `SCAN turnstone:node:*` | Read | Node discovery (node_id, server_url, started) |
| Redis pub/sub | `SUBSCRIBE turnstone:events:cluster` | Read | State changes, creates, closes, renames |
| Node HTTP API | `GET {server_url}/v1/api/dashboard` | Read | Full workstream list with tokens, context, activity |
| Node HTTP API | `GET {server_url}/health` | Read | Node health status |
| Redis inbound queue | `RPUSH turnstone:inbound:{node_id}` | Write | Workstream creation commands |
| `services` table | Database query | Read | Node discovery (node_id, server_url, started) |
| Node SSE | `GET {server_url}/v1/api/events/global` | Stream | Snapshot on connect, then real-time delta events (state, health, aggregate) |
| Node HTTP API | `POST {server_url}/v1/api/workstreams/new` | Write | Workstream creation |
| Node HTTP API | `GET/POST {server_url}/*` | Proxy | Server UI, API requests, SSE streams |
### Redis Key: Cluster Event Channel
Bridges publish to `{prefix}:events:cluster` whenever a workstream state change, creation, closure, or rename occurs. Events include `node_id` so the console can attribute them to the correct node.
Event types on the cluster channel:
| Event | Fields | Trigger |
|-------|--------|---------|
| `cluster_state` | ws_id, state, node_id, tokens, context_ratio, activity | Workstream state transition |
| `ws_created` | ws_id, name, node_id | New workstream created |
| `ws_closed` | ws_id | Workstream closed |
| `ws_rename` | ws_id, name | Workstream renamed |
---
## ClusterCollector
The collector (`turnstone/console/collector.py`) maintains an in-memory snapshot of all nodes and workstreams. Three daemon threads handle data acquisition:
The collector (`turnstone/console/collector.py`) maintains an in-memory snapshot of all nodes and workstreams. Two daemon threads handle data acquisition:
1. **Event subscriber**subscribes to `{prefix}:events:cluster` via `RedisBroker.subscribe_cluster()`. Applies state changes, creates, closes, and renames to the in-memory model immediately.
1. **Node discovery**queries the `services` database table every 60 seconds. Adds newly discovered nodes, removes expired ones (stale heartbeats), emits `node_joined` / `node_lost` events to SSE listeners, and spawns/cancels SSE tasks for new/lost nodes.
2. **Node discovery**scans heartbeat keys every 15 seconds via `broker.list_nodes()`. Adds newly discovered nodes, removes expired ones, emits `node_joined` / `node_lost` events to SSE listeners.
2. **SSE manager**a single asyncio event loop on one thread multiplexes persistent SSE connections to all discovered nodes via `GET /v1/api/events/global`. Each connection receives a `node_snapshot` on connect (workstreams, health, aggregate) followed by real-time delta events (`ws_state`, `ws_created`, `ws_closed`, `ws_rename`, `health_changed`, `aggregate`). On disconnect, the node is marked unreachable and the connection is retried with exponential backoff (1s30s). An `?expected_node_id=` query parameter provides identity verification against IP reuse (server returns 409 on mismatch).
3. **Poll loop** — fetches `GET /v1/api/dashboard` and `GET /health` from each known node every 10 seconds. Uses `ThreadPoolExecutor(max_workers=50)` for parallelism. Each poll replaces the node's workstream list with the authoritative server data.
A `get_snapshot()` method builds the full cluster state under a single lock acquisition — overview aggregates and per-node workstream lists in one atomic read. This is served both as a REST endpoint and as the initial SSE event on client connect.
### Thread Safety
@@ -67,10 +52,11 @@ All reads and writes to the node/workstream map are protected by a single `threa
### Scale Considerations
- **10,000 workstreams** at ~500 bytes each = ~5 MB in memory
- **1,000 nodes** polled in parallel with 50 threads at ~100ms each = ~2 second poll cycle
- **50,000 workstreams** (1,000 nodes × 50 per node) at ~500 bytes each = ~25 MB in memory
- **1,000 nodes** connected via persistent SSE — a single asyncio event loop multiplexes all connections with negligible overhead. Ensure `ulimit -n` >= 4096 for fd headroom
- **Filtering and pagination** run in-memory on the full workstream list — sub-millisecond at this scale
- **SSE fan-out** uses the same per-client queue pattern as the per-node server — backed-up clients get events dropped, not blocking
- **SSE fan-out** uses per-client queues (2,000 events) — backed-up clients get events dropped, not blocking
- **Database** — for clusters sharing PostgreSQL, use [PgBouncer](pgbouncer.md) in transaction pooling mode
---
@@ -146,9 +132,41 @@ Single node detail with all its workstreams.
}
```
### `GET /v1/api/cluster/snapshot`
Full cluster state in a single response — all nodes with their workstreams plus overview aggregates. Built under a single lock for internal consistency. Used by the browser on initial load and SSE reconnect.
```json
{
"nodes": [
{
"node_id": "db-west-04",
"server_url": "http://10.0.3.4:8080",
"max_ws": 10,
"reachable": true,
"version": "0.3.0",
"health": {"status": "ok", "version": "0.3.0"},
"aggregate": {"total_tokens": 48200, "total_tool_calls": 156},
"workstreams": [
{"id": "a1b2c3d4", "name": "perf-db-west", "state": "running", ...}
]
}
],
"overview": {
"nodes": 847,
"workstreams": 4219,
"states": {"running": 1847, "thinking": 312, "attention": 89, "idle": 1940, "error": 31},
"aggregate": {"total_tokens": 12400000, "total_tool_calls": 34200},
"version_drift": false,
"versions": ["0.3.0"]
},
"timestamp": 1709294400.0
}
```
### `POST /v1/api/cluster/workstreams/new`
Create a new workstream on a target node. Dispatches a `CreateWorkstreamMessage` through the Redis MQ pipeline — the bridge on the target node picks it up and creates the workstream on the server. Requires `write` scope.
Create a new workstream on a target node. The console proxies the creation request to the target node's HTTP API. Requires `write` scope.
Request:
@@ -162,9 +180,9 @@ Request:
All fields are optional:
- `node_id` — targeting mode:
- **omitted or `"auto"`** — console picks the reachable node with the most available capacity (max_ws - ws_total) and pushes to its directed queue.
- **`"pool"`** — pushes to the shared inbound queue; the next available bridge picks it up (true general-pool dispatch).
- **specific node ID** — pushes to that node's directed queue.
- **omitted or `"auto"`** — console picks the reachable node with the most available capacity (max_ws - ws_total) and proxies the request to it.
- **`"pool"`** — console picks a reachable node with available capacity using round-robin selection.
- **specific node ID** — proxies the request to that node directly.
- `name` — workstream display name. Auto-generated if omitted.
- `model` — model alias from the target node's registry. Uses the node's default model if omitted.
@@ -178,11 +196,11 @@ Response:
}
```
Creation is asynchronous — the response confirms the MQ message was dispatched. A `ws_created` event on the cluster SSE stream confirms the workstream was actually created.
The response confirms the workstream creation request was proxied to the target node. A `ws_created` event on the cluster SSE stream confirms the workstream was actually created.
### `GET /v1/api/cluster/events`
Server-Sent Events stream for real-time cluster updates.
Server-Sent Events stream for real-time cluster updates. The first event is always a `snapshot` containing the full cluster state (same shape as `GET /v1/api/cluster/snapshot` with an added `type: "snapshot"` field), followed by incremental events:
```
data: {"type":"cluster_state","ws_id":"a1b2","node_id":"db-west-04","state":"running"}
@@ -316,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).
@@ -326,11 +344,11 @@ 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 by creating a per-connection `httpx.AsyncClient(timeout=None)`, streaming the upstream response via `aiter_text()`, parsing SSE framing (`\n\n` delimiters), and re-emitting events through `EventSourceResponse`. Each proxied SSE stream requires its own httpx client since the shared client's 30-second timeout would kill long-lived connections.
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
The proxy forwards the user's JWT to upstream server nodes — it extracts the token from the incoming request's cookie (or `Authorization` header) and adds it as a `Bearer` header on the proxied request. Since all services share the same `TURNSTONE_JWT_SECRET`, the user's JWT is valid on every node without re-authentication. The console's own auth middleware also checks proxy routes — `POST` requests to proxy write endpoints (`/v1/api/send`, `/v1/api/approve`, etc.) require `write` scope, preventing read-only tokens from escalating via proxy. The static `--auth-token` / `proxy_auth_token` is used as a fallback when no user JWT is present.
The proxy mints a short-lived (5-minute) JWT per request carrying the real user's `user_id`, `scopes`, and `permissions` with `aud: turnstone-server`. The user's console JWT (`aud: turnstone-console`) cannot be forwarded directly — it would be rejected by the server's audience validation — so the console re-signs a new server-audience JWT from the validated `AuthResult`. This preserves audit attribution (the upstream server sees the real user, not a service identity) and enforces scope narrowing as defense in depth (a read-only console user's proxied request carries only `read` scope). The JWT `src` claim is set to `"console-proxy"` for audit traceability. When no user context is available (auth disabled), the proxy falls back to a `ServiceTokenManager` with service identity `console-proxy`. The static `--auth-token` / `proxy_auth_token` is used as a final fallback.
---
@@ -360,19 +378,37 @@ Breadcrumb: `Cluster > Running` or `Cluster > db-west-04`. Server-side paginated
Triggered by the "+ new" header button. A modal dialog with:
- **Node selector** — dropdown with three targeting modes: "Auto (best available)" picks the node with the most headroom, "General pool (any node)" pushes to the shared queue for any bridge to pick up, or a specific node from the list (showing capacity).
- **Node selector** — dropdown with three targeting modes: "Auto (best available)" picks the node with the most headroom, "General pool (any node)" picks a node with available capacity using round-robin, or a specific node from the list (showing capacity).
- **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.
All five views receive live updates via SSE — state cards update counts, node rows update metrics, workstream rows update state indicators.
The browser maintains a local `clusterState` object that mirrors the cluster snapshot. It is initialized from the SSE `snapshot` event on connect (or via `GET /v1/api/cluster/snapshot` on initial page load) and updated incrementally by SSE events. View navigation reads from local state — no API round-trips needed after the initial snapshot.
### 5. Admin Panel
Accessed via the "admin" button in the header (visible when authenticated
with `approve` scope). Provides user, API token, and channel link management
with three tabs:
with `approve` scope). Provides user, API token, channel link, MCP server,
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:**
@@ -408,6 +444,22 @@ with three tabs:
- Admins can force-link users who have not self-linked via `/link` in
Discord
**MCP Servers tab:**
The tab has two views toggled via a pill control: **Servers** and
**Registry**.
- **Servers view** -- lists all installed MCP servers with source badges
(CONFIG, MANUAL, REGISTRY), transport badges, tool/resource/prompt
counts, per-node connection status, and CRUD actions for DB-managed
servers
- **Registry view** -- search the official MCP Registry to discover and
install servers. Results show server name, description, version, source
type badges (remote/npm/pypi), and Install/Installed/Update buttons.
Remote servers without required configuration are installed with one
click; servers needing env vars, headers, or URL variables open an
install modal for configuration
**Accessibility:**
- Full keyboard navigation: focus traps in modals, Escape to close, arrow
@@ -425,17 +477,17 @@ to create the initial admin user and receive a JWT in one step. See
## Scheduled Tasks
The console includes a background **TaskScheduler** daemon that creates workstreams on a timed basis via the MQ broker. It supports cron-based recurring schedules and one-shot `at` schedules.
The console includes a background **TaskScheduler** daemon that creates workstreams on a timed basis via HTTP proxy to target nodes. It supports cron-based recurring schedules and one-shot `at` schedules.
### Architecture
The scheduler runs as a daemon thread inside the console process. Every `check_interval` seconds (default 15) it:
1. Acquires a distributed lock via Redis `SET NX EX` (prevents duplicate dispatch in multi-console deployments)
1. Acquires a distributed lock via the `system_settings` table (prevents duplicate dispatch in multi-console deployments)
2. Queries the storage backend for tasks whose `next_run <= now` and `enabled = true`
3. Dispatches each due task as one or more `CreateWorkstreamMessage` via MQ
3. Dispatches each due task as one or more workstream creation requests via HTTP proxy
4. Updates `last_run` and computes the next `next_run` (or disables one-shot `at` tasks)
5. Releases the lock via Lua script (safe conditional delete)
5. Releases the lock
Run history is automatically pruned (runs older than 90 days) approximately once per hour.
@@ -451,7 +503,7 @@ Run history is automatically pruned (runs older than 90 days) approximately once
| Mode | Behavior |
|------|----------|
| `auto` | Picks the reachable node with the most available capacity |
| `pool` | Pushes to the shared inbound queue (any bridge picks it up) |
| `pool` | Picks a reachable node with available capacity using round-robin |
| `all` | Fan-out to all reachable nodes (capped at `max_fan_out`, default 20) |
| `<node_id>` | Targets a specific node by ID |
@@ -588,12 +640,6 @@ CLI flags for `turnstone-console`:
|------|---------|-------------|
| `--host` | `0.0.0.0` | Bind host |
| `--port` | `8090` | HTTP port |
| `--redis-host` | `localhost` | Redis host |
| `--redis-port` | `6379` | Redis port |
| `--redis-password` | `$REDIS_PASSWORD` | Redis password |
| `--redis-db` | `0` | Redis DB |
| `--poll-interval` | `10` | Node polling interval (seconds) |
| `--auth-token` | `$TURNSTONE_AUTH_TOKEN` | Bearer token for server node communication and proxy |
| `--log-level` | `INFO` | Log level |
Config file (`~/.config/turnstone/config.toml`):
@@ -603,12 +649,6 @@ Config file (`~/.config/turnstone/config.toml`):
host = "0.0.0.0"
port = 8090
url = "http://localhost:8090" # used by CLI /cluster commands
poll_interval = 10
[redis]
host = "localhost"
port = 6379
password = "my-redis-password"
```
---
@@ -616,17 +656,11 @@ password = "my-redis-password"
## Deployment
```bash
# Start Redis
redis-server
# Start turnstone servers (one per node)
turnstone-server --port 8080
# Start bridges (one per server)
turnstone-bridge --server-url http://localhost:8080 --node-id node-a
# Start cluster console (one instance)
turnstone-console --redis-host localhost --port 8090 --auth-token "$TURNSTONE_AUTH_TOKEN"
turnstone-console --port 8090
```
Open `http://localhost:8090` for the cluster dashboard. Create workstreams via the "+ new" button. Click any workstream to open the proxied server UI — no direct access to server ports required.
+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.
+162
View File
@@ -0,0 +1,162 @@
# Consistent Hash Ring — Reference Design
**Status**: Reference — alternative routing strategy
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.
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 the ring approach becomes interesting
The vnode ring becomes preferable to rendezvous hashing when:
- 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
### Hash function: FNV-1a (32-bit)
```python
def fnv1a_32(data: bytes) -> int:
"""FNV-1a 32-bit hash.
Basis: 0x811C9DC5, Prime: 0x01000193.
XOR each byte, then multiply by prime (masked to 32 bits).
"""
h = 0x811C9DC5
for b in data:
h ^= b
h = (h * 0x01000193) & 0xFFFFFFFF
return h
```
Known test vectors:
- `fnv1a_32(b"")` = `0x811C9DC5` (basis value)
- `fnv1a_32(b"foobar")` = `0xBF9CF968`
Cross-language implementations:
- **Python**: loop above (no dependencies)
- **Go**: same algorithm with `uint32` arithmetic
- **TypeScript**: same algorithm with `>>> 0` for unsigned 32-bit
### Virtual nodes
Each physical node with weight `w` gets `w * 150` virtual positions on a
16-bit ring (65536 positions). Virtual node `i` of physical node `N` is
placed at:
```
position = fnv1a_32(f"{N.node_id}:{i}".encode()) % 65536
```
With 150 vnodes per unit weight:
- 2 equal-weight nodes: ~50/50 split (measured: 38-62% range due to
hash variance, stddev ~3% with large vnode counts)
- 3 nodes at weights 2:1:1: ~50/25/25 (within 10% tolerance)
### Lookup
```python
def owner(bucket: int) -> str:
"""O(log n) bisect-right walk to find the next virtual node clockwise."""
idx = bisect_right(positions, bucket)
if idx >= len(positions):
idx = 0 # wrap around
return vnode_map[positions[idx]]
```
### Stability properties
The consistent hash ring guarantees:
- **Node addition**: adding a node moves at most `1/N` of buckets (where N
is the new node count). Other nodes' buckets are unaffected.
- **Node removal**: only the removed node's buckets are reassigned. Buckets
owned by surviving nodes don't move.
- **Determinism**: same membership list always produces the same ring.
No coordination needed between processes.
### Full assignment precomputation
```python
def assignments() -> list[tuple[int, str]]:
"""Compute all 65536 bucket-to-node mappings."""
return [(b, owner(b)) for b in range(65536)]
```
This produces a complete assignment table that can be loaded into a flat
array for O(1) request-time lookup. The ring itself is never consulted
on the hot path.
## Data structures
```python
@dataclass(frozen=True, slots=True)
class RingNode:
node_id: str
url: str
weight: int = 1
class HashRing:
"""Immutable consistent hash ring. Thread-safe (no mutable state)."""
def __init__(self, nodes: Sequence[RingNode], vnodes_per_unit: int = 150):
# Validate no duplicate node_ids
# Build sorted array of (position, node_id) tuples
# positions[i] = fnv1a_32(f"{node_id}:{i}".encode()) % RING_SIZE
def owner(self, bucket: int) -> RingNode | None:
# bisect_right + wrap
@property
def version(self) -> int:
# Deterministic hash of membership: fnv1a_32 of sorted node_id:weight pairs
def assignments(self) -> list[tuple[int, str]]:
# Precompute all 65536 bucket assignments
```
## Comparison with rendezvous (HRW) hashing
| 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
For cross-language implementation validation:
```json
{
"fnv1a_32": [
{"input": "", "output": 2166136261},
{"input": "foobar", "output": 3215766888}
],
"bucket_of": [
{"ws_id": "a3f100000000000000000000000000000", "bucket": 41969},
{"ws_id": "00000000000000000000000000000000", "bucket": 0},
{"ws_id": "ffff0000000000000000000000000000", "bucket": 65535}
],
"ring_single_node": {
"nodes": [{"node_id": "n1", "weight": 1}],
"vnodes_per_unit": 150,
"expected_n1_buckets": 65536
}
}
```
+11 -23
View File
@@ -13,24 +13,22 @@ cloud "LLM Providers" as llm {
component [OpenAI-compatible API\n(OpenAI, vLLM, llama.cpp)] as llm_openai
component [Anthropic Messages API] as llm_anthropic
}
database "Redis" as redis
database "SQLite\n(.turnstone.db)" as sqlite
' Turnstone System Boundary
package "Turnstone Platform" {
component [turnstone\n(CLI)] as cli <<entry point>>
component [turnstone-server\n(HTTP + SSE)] as server <<entry point>>
component [turnstone-bridge\n(Queue ↔ HTTP)] as bridge <<service>>
component [turnstone-console\n(Dashboard)] as console <<service>>
component [turnstone-console\n(Dashboard + Router)] as console <<service>>
component [turnstone-eval\n(Headless)] as eval <<entry point>>
component [turnstone-sim\n(Simulator)] as sim <<service>>
component [turnstone-channel\n(Channel Gateway)] as channel <<service>>
}
' User connections
cli_user --> cli : stdin / stdout
browser_user --> server : HTTP + SSE\n(port 8080)
browser_user --> console : HTTP + SSE\n(port 8090)
ext_client --> redis : Redis LIST\n(push commands)
ext_client --> server : HTTP + SSE\n(SDK / API)
eval_user --> eval : Python API
' Internal connections
@@ -43,26 +41,16 @@ server --> sqlite : SQLite
eval --> llm : LLM Provider API\n(non-streaming)
eval --> sqlite : SQLite
bridge --> server : HTTP REST\n(POST /v1/api/send, etc.)
bridge <-- server : SSE\n(GET /v1/api/events)
bridge --> redis : Redis LIST + PUBSUB\n+ STRING (routing, heartbeats)
console --> server : HTTP proxy\n(hash-ring bucket lookup,\nproxy /node/{id}/* traffic)
console --> redis : Redis PUBSUB + STRING + LIST\n(cluster events, heartbeats,\nworkstream creation commands)
console --> server : HTTP polling + reverse proxy\n(GET /v1/api/dashboard,\nproxy /node/{id}/* traffic)
sim --> redis : Redis LIST + PUBSUB\n+ STRING (heartbeats)
channel --> server : HTTP + SSE\n(POST /v1/api/workstreams/{ws_id}/send,\nGET /v1/api/workstreams/{ws_id}/events)
' Notes
note right of sim
Simulator replaces Server+Bridge
with lightweight SimNodes that
publish to the same Redis channels.
end note
note right of redis
Shared message broker:
- LIST: command queues
- STRING: heartbeats, routing
- PUBSUB: event broadcast
note right of console
Multi-node router:
- Hash-ring bucket lookup
- Proxies create/send/approve
- Direct SSE from client to node
- HTTP polling for dashboard
end note
@enduml
+25 -48
View File
@@ -6,13 +6,12 @@ title Turnstone — Package & Module Structure
skinparam component {
BackgroundColor<<entry>> #B8D4E3
BackgroundColor<<core>> #C8E6C9
BackgroundColor<<mq>> #FFE0B2
BackgroundColor<<sim>> #E1BEE7
BackgroundColor<<console>> #B2EBF2
BackgroundColor<<ui>> #F0F4C3
BackgroundColor<<artifact>> #ECEFF1
BackgroundColor<<sdk>> #FFCDD2
BackgroundColor<<api>> #D1C4E9
BackgroundColor<<channel>> #FFE0B2
}
' Entry points
@@ -20,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>>
@@ -40,27 +40,17 @@ package "turnstone/core/" <<Rectangle>> {
component [auth.py\nAuthentication] as auth <<core>>
component [healthcheck.py\nBackendHealthMonitor] as healthcheck <<core>>
component [ratelimit.py\nRateLimiter] as ratelimit <<core>>
component [mcp_client.py\nMCPClientManager] as mcp <<core>>
component [mcp_client.py\nMCPClientManager\n(push + periodic refresh)] as mcp <<core>>
component [tool_search.py\nToolSearchManager, BM25] as toolsearch <<core>>
component [model_registry.py\nModelRegistry] as registry <<core>>
}
' MQ subsystem
package "turnstone/mq/" <<Rectangle>> {
component [protocol.py\n28 message types] as protocol <<mq>>
component [broker.py\nMessageBroker, RedisBroker] as broker <<mq>>
component [bridge.py\nturnstone-bridge] as bridge <<mq>>
component [client.py\nTurnstoneClient] as client <<mq>>
}
' Simulator
package "turnstone/sim/" <<Rectangle>> {
component [cluster.py\nSimCluster] as simcluster <<sim>>
component [node.py\nSimNode, SimWorkstream] as simnode <<sim>>
component [engine.py\nSimEngine] as simengine <<sim>>
component [scenario.py\n5 scenarios] as scenario <<sim>>
component [sim/config.py\nSimConfig] as simconfig <<sim>>
component [sim/metrics.py\nSim metrics] as simmetrics <<sim>>
component [sim/cli.py\nturnstone-sim] as simcli <<sim>>
' Channels
package "turnstone/channels/" <<Rectangle>> {
component [_routing.py\nChannelRouter] as router <<channel>>
component [discord/bot.py\nDiscordBot] as discordbot <<channel>>
component [slack/bot.py\nSlackBot (Socket Mode)] as slackbot <<channel>>
component [cli.py\nturnstone-channel] as gateway <<channel>>
}
' Console
@@ -74,6 +64,7 @@ package "turnstone/ui/" <<Rectangle>> {
component [colors.py\nANSI colors] as colors <<ui>>
component [markdown.py\nMD rendering] as markdown <<ui>>
component [spinner.py\nTerminal spinner] as spinner <<ui>>
component [renderer.js\nBrowser MD + LaTeX] as renderer <<ui>>
}
' API schemas
@@ -95,7 +86,7 @@ package "turnstone/sdk/" <<Rectangle>> {
' Tool schemas
package "turnstone/tools/" <<Rectangle>> {
component [*.json\n14 tool schemas] as schemas <<artifact>>
component [*.json\n19 tool schemas] as schemas <<artifact>>
}
' Entry point dependencies
@@ -123,7 +114,8 @@ eval --> memory
eval --> config
eval --> tools
chat --> session
admin --> auth
bootstrap --> providers
' Core internal deps
session --> providers
@@ -136,6 +128,7 @@ session --> edit
session --> web
session --> healthcheck
session --> mcp : optional
session --> toolsearch : optional
session --> registry : optional
registry --> providers
healthcheck --> metrics
@@ -143,35 +136,19 @@ mcp --> config
registry --> config
tools --> schemas
' MQ dependencies
bridge --> protocol
bridge --> broker
bridge --> config
client --> protocol
client --> broker
' Sim dependencies
simcli --> simcluster
simcli --> simconfig
simcli --> scenario
simcluster --> simnode
simcluster --> broker
simcluster --> simmetrics
simcluster --> simconfig
simnode --> simengine
simnode --> protocol
simnode --> simconfig
simnode --> simmetrics
scenario --> broker
scenario --> protocol
scenario --> simconfig
scenario --> simmetrics
' Channel dependencies
gateway --> discordbot
gateway --> slackbot
gateway --> router
discordbot --> sdkserver : HTTP + SSE
slackbot --> sdkserver : HTTP + SSE
router --> storage : channel_routes
' Console dependencies
consoleserver --> collector
consoleserver --> config
consoleserver --> auth
collector --> broker
collector --> server : HTTP polling
' API dependencies
serverspec --> openapi
+67 -3
View File
@@ -12,7 +12,7 @@ interface "SessionUI" as SessionUI <<Protocol>> {
+ on_content_token(text: str)
+ on_stream_end()
+ approve_tools(items: list) → (bool, str|None)
+ on_tool_result(call_id: str, name: str, output: str)
+ on_tool_result(call_id: str, name: str, output: str, *, is_error: bool = False)
+ on_tool_output_chunk(call_id: str, chunk: str)
+ on_status(usage: dict, ctx_window: int, effort: str)
+ on_plan_review(content: str) → str
@@ -41,7 +41,7 @@ class "WorkstreamTerminalUI" as WsTermUI {
}
class "WebUI" as WebUI {
- _event_queue: Queue
- _listeners: list[Queue]
- _approval_event: Event
- _plan_event: Event
- _ws_prompt_tokens: int
@@ -84,6 +84,8 @@ class "OpenAIProvider" as OpenAIProv {
in OpenAI format.
Search models: web_search_options
+ url_citation annotations.
Extended cache: 24h retention
for GPT-5.x (free).
--
core/providers/_openai.py
}
@@ -94,11 +96,25 @@ class "AnthropicProvider" as AnthropicProv {
Adaptive + manual thinking.
Native web search via
web_search_20250305 server tool.
Auto prompt caching via
cache_control: ephemeral.
Lazy anthropic SDK import.
--
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
@@ -108,6 +124,8 @@ class "ModelCapabilities" as ModelCaps <<frozen>> {
+ thinking_mode: str
+ supports_effort: bool
+ supports_web_search: bool
+ supports_tool_search: bool
+ supports_vision: bool
}
' ChatSession
@@ -120,6 +138,7 @@ class "ChatSession" as ChatSession {
- _msg_tokens: list[int]
- _ws_id: str
- _mcp_client: MCPClientManager | None
- _tool_search: ToolSearchManager | None
- _registry: ModelRegistry | None
+ model_alias: str | None {property}
- _tools: list[dict]
@@ -139,6 +158,12 @@ class "ChatSession" as ChatSession {
- _prepare_tool(tc) → item dict
- _prepare_mcp_tool(call_id, name, args) → item dict
- _exec_mcp_tool(item) → (call_id, output)
- _get_active_tools() → list[dict]
- _prepare_tool_search() → None
- _exec_tool_search(item) → (call_id, output)
- _on_mcp_tools_changed()
- _rebuild_tool_search()
+ close()
- _run_agent(messages, tools, ...) → str
- _compact_messages(auto: bool)
- _full_messages() → list[dict]
@@ -201,22 +226,56 @@ enum "WorkstreamState" as WsState {
' MCPClientManager
class "MCPClientManager" as MCPMgr {
- _sessions: dict[str, ClientSession]
- _per_server_tools: dict[str, list[dict]]
- _per_server_resources: dict[str, list[dict]]
- _per_server_prompts: dict[str, list[dict]]
- _tools: list[dict]
- _tool_map: dict[str, tuple]
- _resource_map: dict[str, tuple]
- _prompt_map: dict[str, tuple]
- _supports_list_changed: dict[str, bool]
- _listeners: list[Callable]
--
+ start()
+ get_tools() → list[dict]
+ get_resources() → list[dict]
+ get_prompts() → list[dict]
+ is_mcp_tool(name) → bool
+ call_tool_sync(name, args) → str
+ read_resource_sync(uri) → str
+ get_prompt_sync(name, args?) → list[dict]
+ refresh_sync(server?) → dict
+ add_listener(callback)
+ remove_listener(callback)
+ server_names: list[str] {property}
+ shutdown()
--
Background asyncio event loop
bridges async MCP SDK to
sync ChatSession dispatch.
Push + periodic + manual refresh.
Resources + prompts discovered
alongside tools at startup.
--
core/mcp_client.py
}
' ToolSearchManager
class "ToolSearchManager" as ToolSearchMgr {
- _always_on: list[dict]
- _deferred: list[dict]
- _expanded: dict[str, None]
- _index: BM25Index
--
+ get_visible_tools() → list[dict]
+ get_deferred_tools() → list[dict]
+ get_expanded_names() → list[str]
+ search(query, k) → list[dict]
+ expand_visible(names) → list[dict]
+ get_search_tool_definition() → dict
+ format_search_results(tools) → str
}
' ModelRegistry
class "ModelRegistry" as ModelReg {
- _models: dict[str, ModelConfig]
@@ -236,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
}
@@ -247,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
@@ -313,10 +375,12 @@ SessionUI <|.. NullUI
LLMProvider <|.. OpenAIProv
LLMProvider <|.. AnthropicProv
OpenAIProv <|-- GoogleProv
ChatSession --> SessionUI : uses
ChatSession --> LLMProvider : delegates LLM calls
ChatSession --> MCPMgr : optional
ChatSession --o ToolSearchMgr : _tool_search
ChatSession --> ModelReg : optional
ChatSession <|-- HeadlessSession
+18 -3
View File
@@ -57,6 +57,14 @@ group loop [while tool_calls present]
end
end
note right of CS
**Cancellation checkpoint:**
_check_cancelled() runs per chunk.
If cancel_event is set, raises
GenerationCancelled — preserves
partial content, emits idle state.
end note
LLM --> CS : stream complete (usage stats)
deactivate LLM
@@ -112,20 +120,21 @@ group loop [while tool_calls present]
note right of TP
Parallel execution:
bash → Popen + line-by-line streaming
read_file → open().read()
read_file → open().read() or base64 image
search → grep subprocess
edit_file → string replace
task/plan → _run_agent() sub-loop
math → sandboxed subprocess
web_fetch → httpx + LLM summarize
web_search → provider-native or Tavily fallback
remember/recall/forget → SQLite
memory/recall → SQLite
end note
note right of TP
bash: on_tool_output_chunk(call_id, line)
called per stdout line,
then on_tool_result(call_id, name, output).
then on_tool_result(call_id, name, output, is_error).
is_error=True when execution failed.
call_id routes chunks/results to correct
tool div during parallel execution.
Other tools: on_tool_result() only.
@@ -144,6 +153,12 @@ group loop [while tool_calls present]
end
note right of CS : Loop back for next LLM call
else GenerationCancelled
CS -> CS : Preserve partial content\nor roll back incomplete tools
CS -> UI : on_info("[Generation cancelled]")
CS -> UI : on_state_change("idle")
CS --> User : return (no re-raise)
end
end
+39 -28
View File
@@ -24,27 +24,33 @@ partition "Phase 1: Prepare" #E8F5E9 {
:Dispatch to _prepare_{func_name}();
note right
**Dispatch table (14 tools):**
┌─────────────┬──────────────────┐
│ Tool │ Needs Approval? │
├─────────────┼──────────────────┤
│ bash │ ✓ Yes │
│ read_file │ ✗ Auto-approve │
│ write_file │ ✓ Yes │
│ edit_file │ ✓ Yes │
│ search │ ✗ Auto-approve │
math │ ✓ Yes
│ man │ ✗ Auto-approve │
web_fetch │ ✓ Yes
│ web_search │ ✓ Yes
task │ ✓ Yes
plan │ ✓ Yes
remember │ ✗ Auto-approve
recall │ ✗ Auto-approve
forget │ ✗ Auto-approve │
├─────────────┼──────────────────┤
mcp__* │ ✓ Yes (external)
└─────────────┴──────────────────┘
**Dispatch table (19 built-in + tool_search):**
┌───────────────┬──────────────────┐
│ Tool │ Needs Approval? │
├───────────────┼──────────────────┤
│ bash │ ✓ Yes │
│ read_file │ ✗ Auto-approve │
│ write_file │ ✓ Yes │
│ edit_file │ ✓ Yes │
│ search │ ✗ Auto-approve │
diff_file │ ✗ Auto-approve
│ math │ ✗ Auto-approve │
man │ ✗ Auto-approve
│ web_fetch │ ✗ Auto-approve
web_search │ ✗ Auto-approve
tool_search │ ✗ Auto-approve
task_agent │ ✓ Yes
plan_agent │ ✓ Yes
memory │ ✗ Auto-approve │
│ recall │ ✗ Auto-approve │
notify │ ✗ Auto-approve
│ watch │ ✓ create only │
│ skill │ ✓ load only │
│ read_resource │ ✓ Yes │
│ use_prompt │ ✓ Yes │
├───────────────┼──────────────────┤
│ mcp__* │ ✓ Yes (external) │
└───────────────┴──────────────────┘
end note
:Build item dict:
@@ -66,8 +72,8 @@ partition "Phase 2: Approve" #FFF3E0 {
**TerminalUI**: Print headers/previews,
prompt [y/n/a, optional message]
If user chose "always":
Set ui.auto_approve = True
(auto-approve all future tools in this session)
Add pending tool names to auto_approve_tools
(auto-approve these tool types going forward)
**WebUI**: Enqueue approve_request,
block on _approval_event.wait()
**NullUI**: Return (True, None)
@@ -86,6 +92,8 @@ partition "Phase 2: Approve" #FFF3E0 {
}
partition "Phase 3: Execute" #E3F2FD {
:_check_cancelled();
note right: Cancellation checkpoint:\nraises GenerationCancelled if\ncancel event is set
if (single tool call?) then (yes)
:Execute sequentially:\nrun_one(items[0]);
else (multiple)
@@ -98,7 +106,7 @@ partition "Phase 3: Execute" #E3F2FD {
if item.denied → return denial message
else → item["execute"](item)
├─ _exec_bash: subprocess.run(["bash", script.sh])
├─ _exec_read_file: open().readlines()
├─ _exec_read_file: open().readlines() or _exec_read_image (base64)
├─ _exec_write_file: makedirs + write
├─ _exec_edit_file: find_occurrences + replace
├─ _exec_search: grep subprocess
@@ -106,11 +114,14 @@ partition "Phase 3: Execute" #E3F2FD {
├─ _exec_man: man/info subprocess
├─ _exec_web_fetch: httpx.get + LLM summary
├─ _exec_web_search: Tavily API POST (fallback for local models)
├─ _exec_tool_search: BM25 search + expand_visible()
├─ _exec_task: _run_agent(TASK_AGENT_TOOLS)
├─ _exec_plan: _run_agent(AGENT_TOOLS, read-only)
├─ _exec_remember: SQLite INSERT OR REPLACE
├─ _exec_recall: SQLite FTS5/LIKE search
├─ _exec_forget: SQLite DELETE
├─ _exec_notify: HTTP POST to channel gateway
├─ _exec_memory: structured memory save/search/delete/list
├─ _exec_recall: conversation history FTS5 search
├─ _exec_read_resource: MCPClientManager.read_resource_sync()
├─ _exec_use_prompt: MCPClientManager.get_prompt_sync()
└─ _exec_mcp_tool: MCPClientManager.call_tool_sync()
end note
@@ -119,7 +130,7 @@ partition "Phase 3: Execute" #E3F2FD {
:_truncate_output() on each result\n(max context_window × chars_per_token × 0.5 chars\ndefault: ~context_window × 2 chars);
:bash: ui.on_tool_output_chunk(call_id, line) per stdout line;
:ui.on_tool_result(call_id, name, output) for each;
:ui.on_tool_result(call_id, name, output, is_error) for each;
if (plan tool was executed?) then (yes)
:ui.on_plan_review(output);
-249
View File
@@ -1,249 +0,0 @@
@startuml
!theme plain
title Turnstone — Message Queue Protocol Types
skinparam classAttributeIconSize 0
skinparam packageStyle rectangle
package "Inbound Messages (Client → Bridge)" #FFF3E0 {
abstract class "InboundMessage" as IM {
+ type: str
+ correlation_id: str {auto: uuid4().hex[:12]}
+ timestamp: float {auto: time.time()}
--
+ to_json() → str
+ {static} from_json(raw) → InboundMessage
}
class SendMessage {
type = "send"
--
+ ws_id: str
+ message: str
+ auto_approve: bool = False
+ auto_approve_tools: list[str] = []
+ name: str = ""
+ target_node: str = ""
}
class ApproveMessage {
type = "approve"
--
+ ws_id: str
+ request_id: str
+ approved: bool = True
+ feedback: str | None
+ always: bool = False
}
class PlanFeedbackMessage {
type = "plan_feedback"
--
+ ws_id: str
+ request_id: str
+ feedback: str
}
class CommandMessage {
type = "command"
--
+ ws_id: str
+ command: str
}
class CreateWorkstreamMessage {
type = "create_workstream"
--
+ name: str = ""
+ auto_approve: bool = False
+ auto_approve_tools: list[str] = []
+ target_node: str = ""
+ initial_message: str = ""
}
class CloseWorkstreamMessage {
type = "close_workstream"
--
+ ws_id: str
}
class ListWorkstreamsMessage {
type = "list_workstreams"
}
class HealthMessage {
type = "health"
}
class ListNodesMessage {
type = "list_nodes"
}
IM <|-- SendMessage
IM <|-- ApproveMessage
IM <|-- PlanFeedbackMessage
IM <|-- CommandMessage
IM <|-- CreateWorkstreamMessage
IM <|-- CloseWorkstreamMessage
IM <|-- ListWorkstreamsMessage
IM <|-- HealthMessage
IM <|-- ListNodesMessage
}
package "Outbound Events (Bridge → Client)" #E3F2FD {
abstract class "OutboundEvent" as OE {
+ type: str
+ ws_id: str
+ correlation_id: str
+ timestamp: float
--
+ to_json() → str
+ {static} from_json(raw) → OutboundEvent
}
package "Streaming" #BBDEFB {
class ContentEvent {
type = "content"
+ text: str
}
class ReasoningEvent {
type = "reasoning"
+ text: str
}
class StreamEndEvent {
type = "stream_end"
}
}
package "Tools" #C8E6C9 {
class ToolInfoEvent {
type = "tool_info"
+ items: list
}
class ApprovalRequestEvent {
type = "approval_request"
+ items: list
..
correlation_id = request_id
}
class ToolOutputChunkEvent {
type = "tool_output_chunk"
+ call_id: str
+ chunk: str
}
class ToolResultEvent {
type = "tool_result"
+ call_id: str
+ name: str
+ output: str
}
class PlanReviewEvent {
type = "plan_review"
+ content: str
}
}
package "Status" #FFF9C4 {
class AckEvent {
type = "ack"
+ status: str
+ detail: str
}
class StatusEvent {
type = "status"
+ prompt_tokens: int
+ completion_tokens: int
+ total_tokens: int
+ context_window: int
+ pct: float
+ effort: str
}
class StateChangeEvent {
type = "state_change"
+ state: str
}
class TurnCompleteEvent {
type = "turn_complete"
}
}
package "Lifecycle" #F8BBD0 {
class WorkstreamCreatedEvent {
type = "ws_created"
+ name: str
}
class WorkstreamClosedEvent {
type = "ws_closed"
}
class WorkstreamListEvent {
type = "ws_list"
+ workstreams: list
}
class WorkstreamRenameEvent {
type = "ws_rename"
+ name: str
}
}
package "System" #E0E0E0 {
class HealthResponseEvent {
type = "health_response"
+ data: dict
}
class ErrorEvent {
type = "error"
+ message: str
}
class InfoEvent {
type = "info"
+ message: str
}
class NodeListEvent {
type = "node_list"
+ nodes: list
}
class ClusterStateEvent {
type = "cluster_state"
+ state: str
+ node_id: str
+ tokens: int
+ context_ratio: float
+ activity: str
+ activity_state: str
}
}
OE <|-- ContentEvent
OE <|-- ReasoningEvent
OE <|-- StreamEndEvent
OE <|-- ToolInfoEvent
OE <|-- ApprovalRequestEvent
OE <|-- ToolResultEvent
OE <|-- PlanReviewEvent
OE <|-- AckEvent
OE <|-- StatusEvent
OE <|-- StateChangeEvent
OE <|-- TurnCompleteEvent
OE <|-- WorkstreamCreatedEvent
OE <|-- WorkstreamClosedEvent
OE <|-- WorkstreamListEvent
OE <|-- WorkstreamRenameEvent
OE <|-- HealthResponseEvent
OE <|-- ErrorEvent
OE <|-- InfoEvent
OE <|-- NodeListEvent
OE <|-- ClusterStateEvent
}
note bottom of IM
**Deserialization**: Strict type-dispatch via _INBOUND_REGISTRY.
Unknown type raises ValueError.
end note
note bottom of OE
**Deserialization**: Lenient type-dispatch via _OUTBOUND_REGISTRY.
Unknown type falls back to base OutboundEvent.
end note
@enduml
-105
View File
@@ -1,105 +0,0 @@
@startuml
!theme plain
title Turnstone — Multi-Node Message Routing
skinparam sequenceArrowThickness 1.5
participant "TurnstoneClient" as Client
collections "Redis" as Redis
participant "Bridge-A\n(node_id: nodeA)" as BridgeA
participant "Bridge-B\n(node_id: nodeB)" as BridgeB
participant "Server-A" as ServerA
== Scenario A: New Message — No Workstream Affinity ==
Client -> Redis : RPUSH turnstone:inbound\n{type:"send", message:"...", ws_id:""}
note right of Redis : Shared queue — any bridge can pick up
BridgeA -> Redis : BLPOP [turnstone:inbound:nodeA,\n turnstone:inbound]
Redis --> BridgeA : SendMessage (from shared queue)
BridgeA -> ServerA : POST /v1/api/workstreams/new\n{name:"", auto_approve:false}
ServerA --> BridgeA : {ws_id:"abc12345", name:"ws-abc1"}
BridgeA -> Redis : SET turnstone:ws:abc12345 "nodeA"
note right : Register workstream ownership
BridgeA -> ServerA : GET /v1/api/events?ws_id=abc12345
note right : Start per-WS SSE thread
BridgeA -> Redis : PUBLISH turnstone:events:global\nWorkstreamCreatedEvent
BridgeA -> Redis : PUBLISH turnstone:events:cluster\nClusterStateEvent(ws_id, state:"idle", node_id:"nodeA")
BridgeA -> ServerA : POST /v1/api/send\n{message:"...", ws_id:"abc12345"}
ServerA --> BridgeA : {status:"ok"}
BridgeA -> Redis : PUBLISH turnstone:events:abc12345\nAckEvent(status:"ok")
... SSE events flow: content, tool_output_chunk, tool_result, status, state_change ...
BridgeA -> Redis : PUBLISH turnstone:events:abc12345\nContentEvent, ToolResultEvent, ...
BridgeA -> Redis : PUBLISH turnstone:events:global\nStateChangeEvent(state:"idle")
BridgeA -> Redis : PUBLISH turnstone:events:abc12345\nTurnCompleteEvent
== Scenario B: Directed Message to Specific Node ==
Client -> Redis : RPUSH turnstone:inbound:nodeB\n{type:"send", target_node:"nodeB", ...}
note right : Per-node queue — only nodeB picks up
BridgeB -> Redis : BLPOP [turnstone:inbound:nodeB,\n turnstone:inbound]
Redis --> BridgeB : SendMessage (from per-node queue, priority)
note right of BridgeB : Process locally on nodeB
== Scenario C: Re-routing (Lands on Wrong Node) ==
Client -> Redis : RPUSH turnstone:inbound\n{type:"send", ws_id:"abc12345"}
BridgeB -> Redis : BLPOP [..., turnstone:inbound]
Redis --> BridgeB : SendMessage (ws_id: abc12345)
BridgeB -> Redis : GET turnstone:ws:abc12345
Redis --> BridgeB : "nodeA"
note right of BridgeB : Owner is nodeA, not me — re-route
BridgeB -> Redis : RPUSH turnstone:inbound:nodeA\n(re-routed message)
BridgeA -> Redis : BLPOP [turnstone:inbound:nodeA, ...]
Redis --> BridgeA : SendMessage (from per-node queue)
note right of BridgeA : Process locally — I own this workstream
== Scenario D: Approval via Response Queue ==
BridgeA <- ServerA : SSE: {type:"approve_request", items:[...]}
note right of BridgeA
Bridge checks auto-approve:
1. _ws_auto_approve[ws_id]? → auto
2. All tools in safe set? → auto
(read_file, search, man,
remember, recall, forget)
3. Otherwise → manual approval
end note
BridgeA -> Redis : PUBLISH turnstone:events:abc12345\nApprovalRequestEvent(correlation_id: req_xyz)
Client <- Redis : (subscribed) ApprovalRequestEvent
Client -> Redis : RPUSH turnstone:resp:req_xyz\nApproveMessage(approved:true)
note right : Response queue — bypasses inbound queue
BridgeA -> Redis : BLPOP turnstone:resp:req_xyz\n(spawned approval thread, timeout 300s)
Redis --> BridgeA : ApproveMessage
BridgeA -> ServerA : POST /v1/api/approve\n{approved:true, ws_id:"abc12345"}
== Heartbeat (continuous) ==
BridgeA -> Redis : SET turnstone:node:nodeA\n{server_url, started} EX 60
note right : Every 30s — TTL 60s
BridgeB -> Redis : SET turnstone:node:nodeB\n{server_url, started} EX 60
@enduml
-98
View File
@@ -1,98 +0,0 @@
@startuml
!theme plain
title Turnstone — Redis Key Schema
skinparam component {
BackgroundColor<<LIST>> #BBDEFB
BackgroundColor<<STRING>> #C8E6C9
BackgroundColor<<PUBSUB>> #FFE0B2
}
skinparam note {
BackgroundColor #FAFAFA
}
package "Queues (Redis LIST)" #E3F2FD {
component [**turnstone:inbound**\n\nShared command queue.\nAny bridge can consume.\n\nOps: RPUSH (write), BLPOP (read)] as inbound <<LIST>>
component [**turnstone:inbound:{node_id}**\n\nPer-node directed queue.\nPriority over shared queue.\n\nOps: RPUSH (write), BLPOP (read)] as inbound_node <<LIST>>
component [**turnstone:resp:{request_id}**\n\nPer-request response queue.\nFor approval / plan feedback.\nTTL: 600s\n\nOps: RPUSH + EXPIRE (write), BLPOP (read)] as resp <<LIST>>
}
package "Routing (Redis STRING)" #E8F5E9 {
component [**turnstone:ws:{ws_id}**\n\nWorkstream → node ownership.\nValue: node_id string.\nNo TTL.\n\nOps: SET, GET, DEL] as ws_owner <<STRING>>
component [**turnstone:node:{node_id}**\n\nNode heartbeat + metadata.\nValue: JSON {server_url, started, ...}\nTTL: 60s (refreshed every 30s)\n\nOps: SET with EX, GET, SCAN] as node_hb <<STRING>>
}
package "Event Channels (Redis PUBSUB)" #FFF3E0 {
component [**turnstone:events:global**\n\nGlobal event broadcast.\nAll state changes, ws lifecycle.\n\nOps: PUBLISH, SUBSCRIBE] as evt_global <<PUBSUB>>
component [**turnstone:events:{ws_id}**\n\nPer-workstream events.\nContent, tools, status.\n\nOps: PUBLISH, SUBSCRIBE] as evt_ws <<PUBSUB>>
component [**turnstone:events:cluster**\n\nCluster-wide state changes.\nUsed by Console dashboard.\n\nOps: PUBLISH, SUBSCRIBE] as evt_cluster <<PUBSUB>>
}
' Readers / Writers
actor "TurnstoneClient" as client
actor "Bridge" as bridge
actor "SimNode" as sim
actor "Console\nCollector" as console
actor "Scenario\n(injector)" as scenario
' Queue interactions
client --> inbound : RPUSH\n(send commands)
client --> inbound_node : RPUSH\n(directed)
scenario --> inbound : RPUSH\n(inject load)
scenario --> inbound_node : RPUSH\n(directed scenario)
bridge --> inbound : BLPOP\n(consume)
bridge --> inbound_node : BLPOP\n(priority)
bridge --> inbound_node : RPUSH\n(re-route)
sim --> inbound_node : BLPOP\n(via dispatcher)
client --> resp : RPUSH\n(approval response)
bridge --> resp : BLPOP\n(wait for approval)
' Routing interactions
bridge --> ws_owner : SET / GET / DEL
client --> ws_owner : GET\n(route lookup)
sim --> ws_owner : SET / DEL
bridge --> node_hb : SET with EX\n(heartbeat)
sim --> node_hb : SET with EX\n(heartbeat)
console --> node_hb : SCAN + GET\n(discovery)
client --> node_hb : SCAN + GET\n(list_nodes)
' Pub/sub interactions
bridge --> evt_global : PUBLISH
bridge --> evt_ws : PUBLISH
bridge --> evt_cluster : PUBLISH
client --> evt_global : SUBSCRIBE
client --> evt_ws : SUBSCRIBE
sim --> evt_global : PUBLISH
sim --> evt_ws : PUBLISH
sim --> evt_cluster : PUBLISH
console --> evt_cluster : SUBSCRIBE
note bottom of inbound
**BLPOP priority**: Bridges call
BLPOP [per-node, shared] so the
per-node queue is always checked first.
end note
note bottom of resp
**Bypasses inbound queue**: Approval
responses go directly to the response
queue, not through the inbound queue.
Auto-cleaned after 600s TTL.
end note
note bottom of evt_cluster
**ClusterStateEvent** includes node_id,
tokens, and context_ratio — enriched
data not available on the global channel.
end note
@enduml
+19 -21
View File
@@ -40,6 +40,23 @@ running --> error : Exception during\ntool execution
error --> thinking : New send() call\n_emit_state("thinking")
thinking --> idle : cancel() called\nstream aborted\n_emit_state("idle")
running --> idle : cancel() called\n_emit_state("idle")
attention --> idle : cancel() unblocks\napproval/plan wait\n_emit_state("idle")
note left of idle
**Cancel escalation:**
1. **Cooperative**: cancel() sets event + closes
SDK stream → worker exits at next checkpoint
2. **Force**: force=true abandons the worker
thread, emits stream_end immediately.
Orphaned thread still kills subprocesses
but skips message mutations (generation
counter prevents stale writes).
end note
note right of thinking
**Emitted via:**
session._emit_state(state)
@@ -47,7 +64,7 @@ note right of thinking
**Propagation:**
• WebUI → global SSE queue (ws_state)
Bridge → PUBLISH to global + cluster channels
Console → HTTP polling picks up state
• CLI → WorkstreamManager.set_state()
end note
@@ -55,27 +72,8 @@ note left of attention
**Blocking mechanisms:**
• TerminalUI: input() prompt
• WebUI: threading.Event.wait()
Bridge: BLPOP on response queue
ChannelBot: SSE event + Discord button
• NullUI: auto-approve (never reaches)
end note
state "SimWorkstream (simplified)" as sim_group {
state "sim_idle" as si <<idle>>
state "sim_thinking" as st <<thinking>>
state "sim_running" as sr <<running>>
state "sim_error" as se <<error>>
[*] --> si
si --> st : process_turn() called
st --> sr : Tool calls generated
sr --> st : More rounds
st --> si : No tools / max rounds
st --> se : Uncaught exception
}
note right of sim_group
SimWorkstream has no ATTENTION state —
tool approval is not simulated.
end note
@enduml
@@ -1,113 +0,0 @@
@startuml
!theme plain
title Turnstone — Simulator Architecture
skinparam component {
BackgroundColor<<cluster>> #E1BEE7
BackgroundColor<<node>> #CE93D8
BackgroundColor<<engine>> #F3E5F5
BackgroundColor<<scenario>> #FFF3E0
BackgroundColor<<metrics>> #E8F5E9
BackgroundColor<<redis>> #FFCDD2
}
package "SimCluster" as cluster <<cluster>> {
component [**ThreadPoolExecutor**\nmax_workers=64\n(blocking Redis ops)] as executor <<cluster>>
component [**redis.ConnectionPool**\nmax_connections=64\ndecode_responses=True\n(shared across all nodes)] as pool <<redis>>
package "InboundDispatchers" {
component [**Dispatcher 0**\nnodes 0-49] as d0
component [**Dispatcher 1**\nnodes 50-99] as d1
component [**...**\n(ceil(N/50) total)] as dn
note bottom of d0
Each dispatcher calls BLPOP on a single Redis
connection for up to 50 node queues + shared queue.
Keys: [prefix:inbound:sim-0000, ..., prefix:inbound]
Per-node keys have BLPOP priority over shared.
end note
}
package "SimNodes (N instances)" {
component [**SimNode sim-0000**] as n0 <<node>>
component [**SimNode sim-0001**] as n1 <<node>>
component [**...**] as nn <<node>>
component [**SimEngine**\n(per node, seeded RNG)\n\nLLM simulation:\n gaussian(μ=2s, σ=0.5s) latency\n gaussian(μ=200, σ=50) tokens\n random word content\n P(tool_calls) = 0.6/0.3\n\nTool simulation:\n gaussian(μ=0.5s, σ=0.2s) latency\n P(failure) = 0.02] as engine <<engine>>
component [**SimWorkstream**\n(0..max_ws per node)\n\nState: idle→thinking→running→idle\nToken accounting: word_count × 3\nContent: 8-chunk streaming] as ws <<node>>
}
component [**MetricsCollector**\n(thread-safe, shared)\n\nTracks: turn latencies,\nthroughput, utilization,\nerrors, node kills] as metrics <<metrics>>
}
package "Scenarios (5 workload patterns)" <<scenario>> {
component [**SteadyState**\nConstant rate:\n1/mps interval\nfor duration secs] as steady <<scenario>>
component [**Burst**\nburst_size messages\nas fast as possible\nthen wait] as burst <<scenario>>
component [**NodeFailure**\nSteadyState + periodic\nnode kills (up to N/2)] as failure <<scenario>>
component [**Directed**\nMessages targeted to\nspecific nodes via\ntarget_node field] as directed <<scenario>>
component [**Lifecycle**\n3 phases:\n1. Create workstreams\n2. Send messages\n3. Close half] as lifecycle <<scenario>>
}
database "Redis" as redis <<redis>>
' Scenario -> Redis
steady --> redis : RPUSH prefix:inbound\n(SendMessage)
burst --> redis : RPUSH prefix:inbound\n(burst)
failure --> redis : RPUSH prefix:inbound
directed --> redis : RPUSH prefix:inbound:{node}\n(directed)
lifecycle --> redis : RPUSH prefix:inbound\n(Create/Send/Close)
' Dispatchers -> Redis -> Nodes
d0 --> redis : BLPOP [per-node..., shared]
d1 --> redis : BLPOP [per-node..., shared]
d0 --> n0 : handle_message(raw)
d0 --> n1 : handle_message(raw)
' Nodes internal
n0 --> engine : simulate_llm_response()\nsimulate_tool_execution()
n0 --> ws : process_turn()
' Nodes -> Redis (events)
n0 --> redis : PUBLISH prefix:events:global\n(StateChangeEvent)
n0 --> redis : PUBLISH prefix:events:{ws_id}\n(ContentEvent, ToolResultEvent, ...)
n0 --> redis : PUBLISH prefix:events:cluster\n(ClusterStateEvent)
n0 --> redis : SET prefix:node:sim-0000\nEX 60 (heartbeat)
n0 --> redis : SET prefix:ws:{ws_id}\n(ownership)
' Shared pool
n0 ..> pool : PooledBroker\n(shared connection)
n1 ..> pool : PooledBroker
d0 ..> pool
d0 ..> executor : asyncio.to_thread()
' Metrics
ws --> metrics : record_turn(ws_id, node_id, latency)
steady --> metrics : record_inject()
burst --> metrics : record_inject()
directed --> metrics : record_inject()
lifecycle --> metrics : record_inject()
cluster --> metrics : record_node_kill(node_id)
cluster --> metrics : snapshot_utilization()\n(every metrics_interval)
note bottom of cluster
**SimConfig** controls all simulation parameters:
num_nodes, max_ws_per_node, redis settings,
llm_latency_mean/stddev, tool_failure_rate,
scenario, duration, messages_per_second, seed
end note
note right of redis
Simulator uses **real Redis** —
not a mock. Console dashboard
can monitor a running simulation
via the same cluster channel.
end note
@enduml
+89 -107
View File
@@ -7,93 +7,79 @@ skinparam sequenceArrowThickness 1.5
participant "Browser" as Browser
participant "Console\nStarlette App" as Server
participant "ClusterCollector" as CC
collections "Redis" as Redis
participant "Node-A Bridge" as BridgeA
participant "Node-A\n(real server)" as NodeA
participant "Node-B\n(sim node)" as NodeB
participant "Node-A\n(server)" as NodeA
participant "Node-B\n(server)" as NodeB
== Thread 1: Cluster Event Subscriber (real-time) ==
== Thread 1: Node Discovery (every 60s) ==
CC -> Redis : SUBSCRIBE turnstone:events:cluster
activate CC #E1BEE7
Redis --> CC : ClusterStateEvent\n{ws_id, state:"thinking",\nnode_id:"nodeA", tokens:500,\ncontext_ratio:0.05}
CC -> CC : Update NodeSnapshot["nodeA"]\n.workstreams["ws123"].state = "thinking"
CC -> CC : _fanout(event) → all SSE listeners
Redis --> CC : {"type":"ws_created",\nws_id:"ws456", name:"task-1",\nnode_id:"sim-0003"}
CC -> CC : Add workstream to\nNodeSnapshot["sim-0003"]
CC -> CC : _fanout(event)
Redis --> CC : ClusterStateEvent\n{ws_id:"ws456", state:"idle"}
CC -> CC : Update workstream state
note right of CC
Handles: cluster_state,
ws_created, ws_closed, ws_rename
Thread runs continuously.
All updates are thread-safe
via threading.Lock.
end note
deactivate CC
== Thread 2: Node Discovery (every 15s) ==
CC -> Redis : SCAN 0 MATCH turnstone:node:*
activate CC #B2EBF2
Redis --> CC : [turnstone:node:nodeA, turnstone:node:sim-0003, ...]
loop for each discovered key
CC -> Redis : GET turnstone:node:{id}
Redis --> CC : JSON: {server_url, started, max_ws, sim:true/false}
end
CC -> CC : Create new NodeSnapshot\nfor newly discovered nodes
CC -> CC : Remove NodeSnapshot\nfor disappeared nodes
CC -> CC : _fanout({type: "node_joined", ...})\n_fanout({type: "node_lost", ...})
deactivate CC
== Thread 3: HTTP Polling (every 10s, real nodes only) ==
CC -> CC : Filter nodes where\nserver_url.startswith("http")
CC -> CC : list_services("server",\nmax_age_seconds=120)
activate CC #C8E6C9
note right of CC
sim:// nodes are SKIPPED.
Their data comes exclusively
from the cluster event channel.
end note
CC -> NodeA : GET /v1/api/dashboard
activate NodeA
NodeA --> CC : {workstreams: [...],\naggregate: {total_tokens, ...}}
deactivate NodeA
CC -> NodeA : GET /health
activate NodeA
NodeA --> CC : {status:"ok", version:"0.3.0",\nmodel:"...", workstreams:{...}}
deactivate NodeA
CC -> CC : Replace NodeSnapshot["nodeA"]\n.workstreams, .health, .aggregate
CC -x NodeB : (SKIPPED: sim:// URL)
CC -> CC : New node? → spawn SSE task\nLost node? → cancel SSE task
CC -> CC : _fanout(node_joined)\n_fanout(node_lost)
deactivate CC
== Thread 2: SSE Manager (asyncio event loop) ==
note over CC
Single asyncio event loop multiplexes
one persistent SSE connection per node.
Scales to 1000+ nodes.
end note
CC -> NodeA : GET /v1/api/events/global\n?expected_node_id=nodeA
activate NodeA
activate CC #BBDEFB
NodeA --> CC : data: {"type":"node_snapshot",\n"node_id":"nodeA",\n"workstreams":[...],\n"health":{...},\n"aggregate":{...}}
note right of CC
Snapshot populates NodeSnapshot
in-memory state. Reconciles
against stale data (emits
ws_created/ws_closed diffs).
end note
loop real-time delta events
NodeA --> CC : data: {"type":"ws_state",\n"ws_id":"ws1","state":"running"}
CC -> CC : Update NodeSnapshot\n_fanout(cluster_state)
end
alt health transition
NodeA --> CC : data: {"type":"health_changed",\n"circuit_state":"open"}
CC -> CC : Update node.health
end
alt periodic aggregate (every 10s)
NodeA --> CC : data: {"type":"aggregate",\n"total_tokens":50000}
CC -> CC : Update node.aggregate
end
deactivate CC
deactivate NodeA
alt SSE disconnect
CC -> CC : Mark node unreachable\nReconnect with backoff\n(1s → 30s cap)
end
alt identity mismatch (409 or snapshot node_id differs)
CC -> CC : Mark node unreachable\nStop reconnecting to this URL
end
== Browser SSE Stream ==
Browser -> Server : GET /v1/api/cluster/events
activate Server
Server -> CC : register_listener(queue)
note right : Per-client queue.Queue(maxsize=500)\nSSE via EventSourceResponse + run_in_executor()
Server -> CC : get_snapshot_and_register(queue)
note right : Atomic: snapshot + listener\nregistration under both locks\n→ no event gap
CC --> Server : ClusterSnapshot\n(full current state)
loop continuous
CC -> Server : event via listener queue\n(from any of the 3 threads)
Server -> Browser : data: {"type":"snapshot",...}\n(full state as first SSE event)
loop continuous (incremental updates)
CC -> Server : event via listener queue\n(from SSE manager thread)
Server -> Browser : data: {"type":"cluster_state",...}\n\n
end
@@ -105,24 +91,31 @@ Browser -> Server : connection closed
Server -> CC : unregister_listener(queue)
deactivate Server
== Browser REST: Snapshot ==
Browser -> Server : GET /v1/api/cluster/snapshot
Server -> CC : get_snapshot()
CC --> Server : ClusterSnapshot\n(full current state)
Server --> Browser : JSON response
== Browser REST Requests ==
Browser -> Server : GET /v1/api/cluster/overview
Server -> CC : get_overview()
CC --> Server : {nodes: 10, workstreams: 47,\nstates: {running:5, ...},\naggregate: {total_tokens: 50000},\nversion_drift: false, versions: ["0.3.0"]}
CC --> Server : {nodes: 2, workstreams: 12,\nstates: {running:5, ...},\naggregate: {total_tokens: 50000},\nversion_drift: false, versions: ["0.9.7"]}
Server --> Browser : JSON response
Browser -> Server : GET /v1/api/cluster/nodes?sort=activity
Server -> CC : get_nodes(sort_by="activity")
CC --> Server : {nodes: [...], total: 10}
CC --> Server : {nodes: [...], total: 2}
Server --> Browser : JSON response
Browser -> Server : GET /v1/api/cluster/workstreams\n?state=running&node=sim-0003
Server -> CC : get_workstreams(state="running",\nnode="sim-0003")
Browser -> Server : GET /v1/api/cluster/workstreams\n?state=running&node=nodeA
Server -> CC : get_workstreams(state="running",\nnode="nodeA")
CC --> Server : {workstreams: [...], total: 5,\npage: 1, per_page: 50, pages: 1}
Server --> Browser : JSON response
== Workstream Creation (via MQ) ==
== Workstream Creation (via Console proxy) ==
Browser -> Server : POST /v1/api/cluster/workstreams/new\n{node_id:"nodeA", name:"new-task"}
activate Server #FFECB3
@@ -130,33 +123,22 @@ activate Server #FFECB3
Server -> CC : _pick_best_node() or\nget_node_detail(node_id)
CC --> Server : node validated
Server -> Server : Build CreateWorkstreamMessage\n{target_node:"nodeA", name:"new-task"}
Server -> NodeA : POST http://nodeA:8080/v1/api/workstreams/new\n{name:"new-task", user_id: from auth_result}
activate NodeA
NodeA --> Server : {ws_id:"ws789", name:"new-task",\nnode_url:"http://nodeA:8080"}
deactivate NodeA
Server -> Redis : RPUSH turnstone:inbound:nodeA\n(directed queue)
Server --> Browser : {status:"ok", correlation_id:"abc",\ntarget_node:"nodeA"}
Server --> Browser : {status:"ok", ws_id:"ws789",\nnode_url:"http://nodeA:8080"}
deactivate Server
note right of Redis
Bridge on Node-A picks up the
message from its directed queue,
POSTs to /v1/api/workstreams/new,
registers ownership, publishes
ws_created to cluster channel.
note right of Server
Console proxies the create request
directly to the target node via HTTP.
The response includes node_url so the
client can establish a direct SSE
connection for the data plane.
end note
Redis --> BridgeA : BLPOP turnstone:inbound:nodeA
activate BridgeA
BridgeA -> NodeA : POST /v1/api/workstreams/new\n{name:"new-task"}
NodeA --> BridgeA : {ws_id:"ws789", name:"new-task"}
BridgeA -> Redis : SET turnstone:ws:ws789 = nodeA
BridgeA -> Redis : PUBLISH turnstone:events:cluster\n{type:"ws_created", ws_id:"ws789",\nnode_id:"nodeA", name:"new-task"}
deactivate BridgeA
Redis --> CC : ws_created event
CC -> CC : Add workstream to\nNodeSnapshot["nodeA"]
CC -> CC : _fanout(event)
Server -> Browser : SSE: data: {"type":"ws_created",...}
== Reverse Proxy (server UI through console port) ==
Browser -> Server : GET /node/nodeA/
@@ -188,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
@@ -207,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
+34 -49
View File
@@ -14,98 +14,83 @@ node "Docker Host" as host {
frame "turnstone-net (bridge network)" as net {
node "redis" <<redis:7.4-alpine>> as redis_node {
component [Redis Server\nport 6379] as redis
note bottom of redis
Healthcheck: redis-cli ping
Volume: redis-data
end note
}
node "server" <<turnstone image>> as server_node {
component [turnstone-server\nport 8080] as server
note bottom of server
Command: turnstone-server
--host 0.0.0.0
--port 8080
Depends: redis (healthy)
Volume: turnstone-data
(/data)
end note
}
node "bridge ×N" <<turnstone image>> as bridge_node {
component [turnstone-bridge] as bridge
note bottom of bridge
Command: turnstone-bridge
--server-url http://server:8080
--redis-host redis
Depends: server + redis
Scalable: --scale bridge=N
node_id: auto from hostname
end note
}
node "console" <<turnstone image>> as console_node {
component [turnstone-console\nport 8090] as console
note bottom of console
Command: turnstone-console
--redis-host redis
--port 8090
Depends: redis
Depends: server
Hash-ring router for
multi-node clusters
end note
}
node "sim (profile: sim)" <<turnstone image>> as sim_node {
component [turnstone-sim] as sim
note bottom of sim
Command: turnstone-sim
--redis-host redis
--nodes 100
--scenario steady
Depends: redis
Optional: only with
--profile sim
node "postgres (profile: production)" <<pgautoupgrade>> as pg_node {
component [PostgreSQL\nport 5432] as postgres
note bottom of postgres
Healthcheck: pg_isready
Volume: postgres-data
Required for cluster
and production profiles
end note
}
node "pgbouncer (optional)" <<bitnami/pgbouncer>> as pgb_node {
component [PgBouncer\nport 6432] as pgbouncer
note bottom of pgbouncer
pool_mode: transaction
Recommended for clusters
> 50 nodes
See docs/pgbouncer.md
end note
}
}
}
actor "Browser\nUser" as browser
actor "MQ Client" as mqclient
actor "SDK /\nAPI Client" as apiclient
' External connections
browser --> server : HTTP + SSE\nport 8080
browser --> console : HTTP + SSE\nport 8090
mqclient --> redis : Redis protocol\nport 6379
apiclient --> server : HTTP + SSE\nport 8080
' Internal connections
server --> redis : Redis protocol\n(6379)
server --> llm_api : OpenAI API\n(HTTPS/HTTP)
bridge --> server : HTTP REST\n(POST /v1/api/send, etc.)
bridge <-- server : SSE\n(GET /v1/api/events)
bridge --> redis : Redis protocol\n(queues + pubsub)
console --> server : HTTP proxy\n(hash-ring lookup,\nproxy /node/{id}/*)
console --> redis : Redis PUBSUB + LIST\n(cluster events,\nws creation commands)
console --> server : HTTP polling + proxy\n(GET /v1/api/dashboard,\nproxy /node/{id}/*)
sim --> redis : Redis protocol\n(queues + pubsub + keys)
' Database connections (production/cluster profiles)
server ..> pgbouncer : PostgreSQL\n(pool_size=2)
console ..> pgbouncer : PostgreSQL\n(auth/admin)
pgbouncer --> postgres : transaction\npooling
' Environment variables
note right of host
**Environment Variables:**
LLM_BASE_URL LLM endpoint
OPENAI_API_KEY API key
• REDIS_PASSWORD — Redis auth
TURNSTONE_AUTH_TOKEN — API auth
* LLM_BASE_URL -- LLM endpoint
* OPENAI_API_KEY -- API key
* TURNSTONE_AUTH_TOKEN -- API auth
* TURNSTONE_DB_URL -- PostgreSQL URL
* POSTGRES_PASSWORD -- DB password
end note
' Volumes
database "redis-data" as rv
database "turnstone-data" as tv
database "postgres-data" as pv
redis_node --> rv
server_node --> tv
pg_node --> pv
@enduml
+3
View File
@@ -32,6 +32,7 @@ package "turnstone/sdk/ (Python)" {
+ approve()
+ plan_feedback()
+ command()
+ cancel(ws_id)
+ stream_events(ws_id)
+ stream_global_events()
+ send_and_wait()
@@ -45,6 +46,7 @@ package "turnstone/sdk/ (Python)" {
+ nodes()
+ workstreams()
+ node_detail()
+ snapshot()
+ create_workstream()
+ stream_cluster_events()
+ login() / logout()
@@ -129,6 +131,7 @@ package "sdk/typescript/ (TypeScript)" {
class "TurnstoneConsole" as TSConsole <<ts>> {
+ overview()
+ nodes()
+ snapshot()
+ clusterEvents()
...
}
+9 -7
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
@@ -53,10 +53,10 @@ class "SQLiteBackend" as SQLite <<sqlite>> {
class "PostgreSQLBackend" as PG <<postgres>> {
-_engine: sa.Engine
+__init__(url: str, pool_size: int)
+__init__(url: str, pool_size: int = 2,\n max_overflow: int = 3)
--
tsvector + ILIKE search
Connection pooling
Connection pooling (5 max per process)
}
' -- Schema --
@@ -64,11 +64,12 @@ class "_schema.py" as Schema <<schema>> {
+metadata: MetaData
+memories: Table
+conversations: Table
+workstreams: Table (node_id, alias, title, state)
+workstreams: Table (node_id, alias, title,\n state, skill_id)
+workstream_config: Table
+users: Table (username, password_hash)
+api_tokens: Table (token_hash, scopes)
+channel_users: Table (channel_type)
+scheduled_tasks: Table (..., skill)
--
SQLAlchemy Core
Single source of truth
@@ -150,7 +151,7 @@ note right of Registry
backend = "sqlite" | "postgresql"
url = "postgresql+psycopg://..."
path = ".turnstone.db"
pool_size = 5
pool_size = 2 (+ 3 overflow)
end note
note bottom of SQLite
@@ -161,8 +162,9 @@ end note
note bottom of PG
Production backend.
Multi-node / Docker
default.
Multi-node / Docker default.
Use PgBouncer (transaction mode)
for clusters > 50 nodes.
end note
@enduml
+12 -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
}
@@ -176,4 +176,15 @@ note bottom of SH
Both share JWT signing secret
end note
note left of JWT
**Console Proxy Token Minting**
When proxying requests to server nodes:
1. Console AuthMiddleware validates user JWT (aud: turnstone-console)
2. Proxy mints new JWT (aud: turnstone-server)
with real user_id, scopes, permissions
3. src: "console-proxy" for audit traceability
4. 5-minute expiry (fresh per request)
5. Fallback: ServiceTokenManager if no user context
end note
@enduml
+71 -77
View File
@@ -5,8 +5,6 @@ title Turnstone — Channel Integration Architecture
skinparam class {
BackgroundColor<<platform>> #E1BEE7
BackgroundColor<<service>> #E8EAF6
BackgroundColor<<mq>> #FFCDD2
BackgroundColor<<bridge>> #C8E6C9
BackgroundColor<<server>> #FFE0B2
BackgroundColor<<storage>> #B3E5FC
}
@@ -22,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>> {
@@ -40,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
@@ -49,10 +50,24 @@ 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)
+send(channel_id, content)
+send_notification(channel_id, content, ws_id)
+run(token)
--
discord.py Client
@@ -61,56 +76,29 @@ class "DiscordBot" as Bot <<service>> {
Creates threads for workstreams
Renders approval buttons
escape_mentions() on send
--
_notify_ws_map: msg_id -> (ws_id, user_id)
_notify_reply_channels: ws_id -> (dm, user_id)
}
class "ChannelRouter" as Router <<service>> {
+resolve_route(platform, channel_id)
ws_id | None
-> ws_id | None
+register_route(channel_id, ws_id)
+resolve_identity(platform, platform_user_id)
user_id | None
-> user_id | None
--
Maps channels workstreams
Maps platform users turnstone users
Maps channels -> workstreams
Maps platform users -> turnstone users
Caches routes in memory
}
class "AsyncRedisBroker" as Broker <<service>> {
+push_inbound(msg)
+subscribe(ws_id) → AsyncIterator
+subscribe_global() → AsyncIterator
+push_response(correlation_id, msg)
--
redis.asyncio client
Pub/sub + queue operations
}
' -- Redis MQ --
class "Redis MQ" as Redis <<mq>> {
turnstone:inbound (LIST)
turnstone:events:{ws_id} (PUBSUB)
turnstone:events:global (PUBSUB)
turnstone:resp:{corr_id} (LIST)
--
Shared message bus
Same queues as bridge protocol
}
' -- Bridge + Server --
class "turnstone-bridge" as Bridge <<bridge>> {
BLPOP turnstone:inbound
Drive server via HTTP
Relay SSE → Redis pub/sub
--
Owns workstream lifecycle
Auto-approve / manual approve
}
' -- 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
@@ -124,7 +112,7 @@ class "channel_users" as CU <<storage>> {
channel_user_id (PK)
platform: "discord" | "slack"
platform_user_id
user_id users
user_id -> users
linked_at
--
/link command creates row
@@ -157,29 +145,28 @@ class "services" as SVC <<storage>> {
' -- Relationships --
Discord --> Bot : gateway\nevents
Bot --> Router : on_message\non_interaction
Router --> Broker : SendMessage\nApproveMessage
Router --> CU : resolve identity
Router --> CR : resolve / register route
Broker --> Redis : RPUSH inbound\nRPUSH resp:{id}
Redis --> Bridge : BLPOP inbound
Bridge --> Server : HTTP API
Server --> Bridge : SSE events
Bridge --> Redis : PUBLISH events:{ws_id}\nPUBLISH events:global
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
Redis --> Broker : SUBSCRIBE events:{ws_id}
Broker --> Bot : 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 --> Broker : creates
ChannelService --> SVC : register / heartbeat /\nderegister
' -- Notification path (direct HTTP, bypasses MQ) --
' -- Notification path (direct HTTP) --
Server --> ChannelService : POST /v1/api/notify\n(JWT: aud=turnstone-channel)
Server --> SVC : list_services("channel",\nmax_age_seconds=120)
@@ -188,38 +175,36 @@ note right of Bot
**Inbound Flow**
1. Discord message arrives via gateway
2. Bot.on_message() fires
3. ChannelRouter resolves channel ws_id
3. ChannelRouter resolves channel -> ws_id
(or creates new workstream)
4. ChannelRouter resolves platform user user_id
4. ChannelRouter resolves platform user -> user_id
via channel_users table
5. Broker.push_inbound(SendMessage)
6. Bridge pops from Redis, drives server
5. Router sends POST /v1/api/workstreams/{ws_id}/send to server
**Workstream Resume (evicted workstreams)**
1. Stale route detected (no MQ owner)
1. Stale route detected (no active SSE listener)
2. Existing ws_id reused directly from route
3. CreateWorkstreamMessage sent with
3. POST /v1/api/workstreams/new with
resume_ws=<ws_id>
4. Server resumes atomically during creation
5. Bridge emits WorkstreamResumedEvent thread
5. SSE emits WorkstreamResumedEvent -> thread
end note
note right of Broker
note right of Server
**Outbound Flow**
1. Server emits SSE events
2. Bridge relays to Redis events:{ws_id}
3. Broker.subscribe(ws_id) yields events
4. Bot formats and sends to Discord thread
1. Server emits SSE events on
GET /v1/api/workstreams/{ws_id}/events
2. Bot subscribes via httpx-sse
3. Bot formats and sends to Discord thread
end note
note bottom of CR
**Approval Flow**
1. ApprovalRequestEvent arrives via events:{ws_id}
1. ApprovalRequestEvent arrives via SSE
2. Bot renders Discord buttons (Approve / Deny)
3. User clicks button on_interaction()
3. User clicks button -> on_interaction()
4. Router builds ApproveMessage
5. Broker.push_response(correlation_id, msg)
6. Bridge pops from resp:{id}, calls POST /api/approve
5. Router sends POST /v1/api/workstreams/{ws_id}/approve to server
end note
note bottom of CU
@@ -234,17 +219,26 @@ note bottom of CU
end note
note bottom of SVC
**Notification Flow** (direct HTTP, bypasses MQ)
1. LLM calls notify tool _prepare_notify()
**Notification Flow** (direct HTTP)
1. LLM calls notify tool -> _prepare_notify()
2. _exec_notify() checks rate limit (5/turn)
3. Queries services table for healthy gateways
4. Mints JWT (aud: turnstone-channel) via
ServiceTokenManager
5. POSTs to first healthy gateway
5. POSTs to first healthy gateway (incl. ws_id)
6. Gateway validates JWT, resolves target
7. adapter.send() Discord API
8. On failure: retry up to 3× (1s, 3s backoff)
7. adapter.send_notification() -> Discord API
(tracks msg_id -> ws_id for reply routing)
8. On failure: retry up to 3x (1s, 3s backoff)
9. SSRF: only http(s) URLs allowed
**Bidirectional DM Replies**
1. User replies to notification DM
2. Bot looks up ws_id from _notify_ws_map
3. Verifies author == notification recipient
4. Routes reply via router.send_message()
5. Response forwarded to DM on TurnCompleteEvent
6. Response tracked for multi-turn conversation
end note
@enduml
+35
View File
@@ -103,6 +103,41 @@ alt all retries exhausted
Session --> Session : "Error: notification delivery failed"
end
== Bidirectional Reply (User responds to notification DM) ==
Discord -> Adapter : user replies to\nnotification message
Adapter -> Adapter : lookup message_id\nin _notify_ws_map
note right
Maps message_id →
(ws_id, target_user_id)
Atomic pop prevents TOCTOU
end note
alt message not tracked
Adapter -> Discord : "This notification\nis no longer active."
else tracked
Adapter -> Adapter : verify author ==\ntarget_user_id
Adapter -> Adapter : resolve_user()\n(unlinked → drop)
Adapter -> Adapter : router.send_message(ws_id, content)
note right
Routes reply via MQ to
the originating workstream.
Registers DM channel in
_notify_reply_channels[ws_id]
end note
... workstream processes reply ...
Adapter <- Adapter : TurnCompleteEvent\n(with content)
Adapter -> Discord : forward response to DM
Adapter -> Adapter : track response message\nfor multi-turn replies
note right
Response message_id added
to _notify_ws_map — user can
reply again indefinitely
end note
end
== Service Registry (Background) ==
note over Gateway, Storage
+166
View File
@@ -0,0 +1,166 @@
@startuml
!theme plain
title Turnstone — Watch Tool Architecture
skinparam participant {
BackgroundColor<<server>> #FFE0B2
BackgroundColor<<storage>> #B3E5FC
BackgroundColor<<session>> #C8E6C9
BackgroundColor<<ui>> #E8EAF6
}
participant "ChatSession\n(session.py)" as Session <<session>>
participant "WatchRunner\n(watch.py)" as Runner <<server>>
participant "StorageBackend\n(SQLite)" as Storage <<storage>>
participant "WebUI / SSE\n(server.py)" as UI <<ui>>
== Create Phase ==
Session -> Session : _prepare_watch(action="create")
note right
Validates:
- command via is_command_blocked()
- poll_every → parse_duration()
- stop_on → validate_condition()
- max watches limit (5)
- duplicate name check
needs_approval = True
end note
Session -> Storage : create_watch(watch_id, ws_id,\nnode_id, command, interval,\nstop_on, max_polls, next_poll)
Session --> UI : tool_result:\n"Watch 'pr-review' created"
== Poll Phase (WatchRunner daemon, every 15s) ==
Runner -> Storage : list_due_watches(now)
Storage --> Runner : due_watches[]
note right
Filters:
active=1 AND
next_poll <= now AND
node_id matches
end note
loop for each due watch
Runner -> Runner : is_command_blocked()?
alt blocked
Runner -> Storage : update_watch(active=False)
else safe
Runner -> Runner : subprocess.run(command)
note right
timeout = tool_timeout
start_new_session = True
output truncated at 64KB
end note
Runner -> Runner : evaluate_condition(\nstop_on, output,\nexit_code, prev_output)
note right
**Variables:**
output, data, exit_code,
prev_output, changed
**Safe builtins only:**
len, str, int, sorted, ...
No import/open/exec/eval
**stop_on=None:**
fires on change (skip 1st poll)
end note
alt condition fired OR max_polls reached
Runner -> Storage : update_watch(\npoll_count++,\nlast_output, active=False)
Runner -> Runner : format_watch_message()
Runner -> Runner : _dispatch_result(ws_id, msg)
else not fired
Runner -> Storage : update_watch(\npoll_count++,\nlast_output, next_poll)
end
end
end
== Dispatch Phase ==
note over Runner, Session
**Three dispatch paths:**
end note
alt Path A: workstream active + idle
Runner -> Session : dispatch_fn(message)\n→ _watch_pending.put()
Session -> Session : _dispatch_pending_watch()\n→ self.send(message)
Session -> UI : SSE: thinking, content,\ntool calls...
note right
Watch result appears as
synthetic user message.
Model sees it and responds.
Depth guard: max 5 chains.
end note
else Path B: workstream active + busy
Runner -> Session : dispatch_fn(message)\n→ _watch_pending.put()
note right
Queued. Dispatched when
current send() reaches IDLE.
end note
else Path C: workstream evicted
Runner -> Runner : restore_fn(ws_id)
note right
1. mgr.create() — may evict
another idle workstream
2. session.resume(ws_id)
3. set_watch_runner()
4. register new dispatch_fn
end note
Runner -> Session : restored dispatch_fn(message)
end
== Cancel / List ==
Session -> Storage : list_watches_for_ws(ws_id)
note right : action="list" (auto-approve)
Session -> Storage : update_watch(active=False)
note right : action="cancel" (auto-approve)
== Server Lifecycle ==
note over Runner, Storage
**Startup:**
1. WatchRunner created in main() with storage + node_id
2. restore_fn closure captures WorkstreamManager
3. Initial workstream: session.set_watch_runner(runner)
4. _lifespan(): runner.start() — daemon thread begins
**New workstream:**
session.set_watch_runner(runner) in create_workstream()
→ registers dispatch_fn for ws_id
**Eviction / close:**
session.close() → runner.remove_dispatch_fn(ws_id)
Watches remain active in DB — WatchRunner uses restore_fn
**Restart recovery:**
Overdue watches fire ONE immediate poll
next_poll updated to now + interval
Normal cadence resumes
**Shutdown:**
_lifespan(): runner.stop() — joins thread
end note
== REST API ==
note over UI, Storage
**GET /v1/api/watches[?ws_id=X]**
List active watches (for node or workstream)
**POST /v1/api/watches/{watch_id}/cancel**
Cancel a watch (sets active=False)
Both require write scope
end note
@enduml
@@ -0,0 +1,100 @@
@startuml
!theme plain
skinparam backgroundColor #FFFFFF
skinparam defaultFontName "IBM Plex Mono"
skinparam componentStyle rectangle
title Turnstone Governance Architecture
package "Auth Flow" {
[Login/Token Auth] as auth
[_load_user_permissions()] as perms
[_permissions_to_scopes()] as scopes
[create_jwt()] as jwt
}
package "Middleware" {
[AuthMiddleware\n(scope check)] as mw
[require_permission()\n(granular check)] as rp
}
package "Governance Storage" {
database "roles" as roles_db
database "user_roles" as ur_db
database "orgs" as orgs_db
database "tool_policies" as tp_db
database "prompt_templates\n(skills)" as pt_db
database "usage_events" as ue_db
database "audit_events" as ae_db
database "skills" as wt_db
}
package "Runtime Enforcement" {
[evaluate_tool_policies_batch()] as eval
[WebUI.approve_tools()] as approve
[record_usage_event()\n+cache_creation/read_tokens] as usage
[record_audit()] as audit
}
package "Template Runtime" {
[_load_templates()] as tload
[_render_template()\n{{model}}, {{ws_id}}, {{node_id}}] as trender
[_init_system_messages()] as tsys
[set_template() / /template] as tset
}
package "Skill Runtime" {
[resolve_skill()] as wtr
[apply settings\n(model, budget, prompt)] as wta
[budget gate\n(session.send)] as wtb
}
package "Console UI" {
[Admin Panel\n10 tabs] as ui
[governance.js] as govjs
[sessionStorage\npermissions] as ss
}
auth --> perms : user_id
perms --> roles_db : JOIN user_roles + roles
perms --> scopes : permission set
scopes --> jwt : scopes + permissions
jwt --> mw : JWT in cookie/header
mw --> rp : scope OK → check permission
rp --> ui : 403 or allow
eval --> tp_db : list_tool_policies()
approve --> eval : tool names
approve --> ae_db : (via audit)
usage --> ue_db : on_status()
audit --> ae_db : admin handlers
govjs --> roles_db : /v1/api/admin/roles
govjs --> tp_db : /v1/api/admin/policies
govjs --> pt_db : /v1/api/admin/skills
govjs --> ue_db : /v1/api/admin/usage
govjs --> ae_db : /v1/api/admin/audit
tload --> pt_db : list_default_templates()\nor get_by_name()
tload --> trender : template content
trender --> tsys : rendered content
tset --> tload : name or None
note right of pt_db
Read-only listing:
GET /v1/api/skills
(read scope, summary only)
end note
govjs --> wt_db : /v1/api/admin/skills
wtr --> wt_db : get_skill_by_name()
wtr --> wta : skill settings
wta --> pt_db : skill lookup
wtb --> approve : __budget_override__
auth -[hidden]-> mw
mw -[hidden]-> approve
@enduml
+230
View File
@@ -0,0 +1,230 @@
@startuml
!theme plain
title Turnstone — MCP Architecture (Resources, Prompts, Tools)
skinparam participant {
BackgroundColor<<mcp>> #E1BEE7
BackgroundColor<<session>> #C8E6C9
BackgroundColor<<storage>> #B3E5FC
BackgroundColor<<server>> #FFE0B2
BackgroundColor<<ui>> #E8EAF6
BackgroundColor<<registry>> #F8BBD0
}
participant "MCP Server\n(external)" as MCPSrv <<mcp>>
participant "MCPClientManager\n(mcp_client.py)" as MCPMgr <<mcp>>
participant "ChatSession\n(session.py)" as Session <<session>>
participant "StorageBackend\n(governance)" as Storage <<storage>>
participant "Server / Console\n(health + UI)" as UI <<server>>
participant "Console Admin UI\n(admin panel)" as Admin <<ui>>
participant "Database\n(mcp_servers table)" as DB <<storage>>
participant "MCPRegistryClient\n(mcp_registry.py)" as RegClient <<mcp>>
participant "MCP Registry\n(registry.modelcontextprotocol.io)" as Registry <<registry>>
== Admin-Driven Configuration ==
Admin -> DB : CRUD MCP server definitions\n(POST/PUT/DELETE /v1/api/admin/mcp-servers)
Admin -> UI : POST /v1/api/admin/mcp-servers/reload
UI -> MCPMgr : POST /_internal/mcp-reload\n(forwarded to each node)
MCPMgr -> MCPMgr : reconcile_sync()
note right
Diffs running servers against DB:
- New entries → connect
- Removed entries → disconnect
- Changed entries → reconnect
end note
== Registry Discovery & Install ==
Admin -> UI : GET /v1/api/admin/mcp-registry/search?search=...
UI -> RegClient : search(q, limit, cursor)
RegClient -> Registry : GET /v0.1/servers?search=...&latest=true
Registry --> RegClient : Server entries\n(remotes, packages, meta)
RegClient --> UI : RegistrySearchResult\n(annotated with installed status)
UI --> Admin : Search results\n(Install / Installed badges)
Admin -> UI : POST /v1/api/admin/mcp-registry/install
UI -> DB : create_mcp_server()\n(registry_name, version, meta)
UI -> MCPMgr : POST /_internal/mcp-reload\n(fan-out to nodes)
MCPMgr -> MCPMgr : reconcile_sync()
MCPMgr -> MCPSrv : connect to new server
note over RegClient, Registry
MCPRegistryClient is an async httpx client
targeting registry.modelcontextprotocol.io/v0.1.
resolve_install_config() translates registry
remotes/packages into mcp_servers rows.
end note
== Startup: Connection & Discovery ==
MCPMgr -> DB : load_mcp_config(storage=)\n(merge config file + DB)
MCPMgr -> MCPSrv : initialize (stdio or HTTP)
MCPSrv --> MCPMgr : capabilities\n(tools, resources, prompts)
MCPMgr -> MCPSrv : tools/list
MCPSrv --> MCPMgr : Tool[]
opt resources capability
MCPMgr -> MCPSrv : resources/list
MCPSrv --> MCPMgr : Resource[]
MCPMgr -> MCPSrv : resources/templates/list
MCPSrv --> MCPMgr : ResourceTemplate[]
end
opt prompts capability
MCPMgr -> MCPSrv : prompts/list
MCPSrv --> MCPMgr : Prompt[]
end
note over MCPMgr
Per-server storage:
_per_server_tools, _per_server_resources, _per_server_prompts
Copy-on-write rebuild into _tools, _resources, _prompts
Prefix: mcp__{server}__{name}
end note
MCPMgr -> Session : notify tool listeners
MCPMgr -> Session : notify resource listeners
== Governance Sync (on connect & refresh) ==
MCPMgr -> Storage : sync_prompts_to_storage()
note right
For each MCP prompt:
- Manual template exists? → skip
- MCP template exists? → update
(reset is_default=False)
- New? → create (origin="mcp",
readonly=True, is_default=False)
Removed prompts → delete
Protected by _sync_lock
end note
== set_storage() from entry point ==
UI -> MCPMgr : set_storage(backend)
note right
If servers already connected,
triggers immediate sync
end note
== Runtime: Tool Execution ==
Session -> Session : _prepare_mcp_tool(func_name, args)
note right
approval_label = func_name
(e.g. mcp__github__search)
needs_approval = True
end note
Session -> MCPMgr : call_tool_sync(name, args)
MCPMgr -> MCPSrv : tools/call
MCPSrv --> MCPMgr : ToolResult
MCPMgr --> Session : output (text)
== Runtime: Resource Read ==
Session -> Session : _prepare_read_resource(uri)
note right
approval_label = mcp_resource__{normalized_uri}
URI normalized (.. resolved)
needs_approval = True
end note
Session -> MCPMgr : read_resource_sync(uri)
MCPMgr -> MCPSrv : resources/read
MCPSrv --> MCPMgr : ReadResourceResult
MCPMgr --> Session : content (text/blob)
== Runtime: Prompt Invocation ==
Session -> Session : _prepare_use_prompt(name, arguments)
note right
approval_label = mcp__srv__prompt
Validated via is_mcp_prompt()
needs_approval = True
end note
Session -> MCPMgr : get_prompt_sync(name, args)
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 (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
MCPMgr -> MCPMgr : _refresh_server_resources()
MCPSrv -> MCPMgr : PromptListChangedNotification
MCPMgr -> MCPMgr : _refresh_server_prompts()
MCPMgr -> Storage : sync_prompts_to_storage()
end
group Periodic Polling (default 4h)
MCPMgr -> MCPMgr : _periodic_refresh()
note right
Only polls capabilities
without push support.
Staggered per-server.
Disconnected servers get
reconnect attempts with
exponential backoff (60s-1h).
end note
end
group Manual Refresh
Session -> MCPMgr : refresh_sync()
note right: /mcp refresh [server]
end
== Policy Evaluation ==
note over Session
Tool policies use fnmatch on approval_label:
- mcp__github__* → allow (all GitHub tools/prompts)
- mcp_resource__file:///docs/* → allow
- mcp_resource__* → deny (block all resource reads)
- mcp__untrusted__* → ask
end note
== UI Visibility ==
UI -> MCPMgr : server_count, get_resources(), get_prompts()
note over UI
/health → mcp.servers, mcp.resources, mcp.prompts
Server UI: magenta status badge
Console: cluster status bar + node detail
System message: <mcp-resources> + <mcp-prompts> catalogs
end note
@enduml
+218
View File
@@ -0,0 +1,218 @@
@startuml
!theme plain
title Turnstone — Intent Validation (Judge) Architecture
skinparam participant {
BackgroundColor<<session>> #C8E6C9
BackgroundColor<<judge>> #FFE0B2
BackgroundColor<<storage>> #B3E5FC
BackgroundColor<<ui>> #E8EAF6
BackgroundColor<<fs>> #F5F5F5
}
participant "ChatSession\n(session.py)" as Session <<session>>
participant "IntentJudge\n(judge.py)" as Judge <<judge>>
participant "LLM Provider\n(provider)" as LLM <<judge>>
participant "StorageBackend\n(SQLite)" as Storage <<storage>>
participant "WebUI / SSE\n(server.py)" as UI <<ui>>
participant "Filesystem" as FS <<fs>>
== Tool Call Requires Approval ==
Session -> Session : _prepare_tool_calls()
note right
Tool calls parsed from
LLM response. Auto-approved
tools dispatched immediately.
Remaining items need approval.
end note
Session -> Session : _evaluate_intent(pending_items)
== Tier 1: Heuristic (synchronous, sub-ms) ==
Session -> Judge : evaluate(items, messages, callback)
Judge -> Judge : evaluate_heuristic()\nfor each item
note right
**36 rules (first match wins):**
Critical (0.90, deny): rm /, mkfs,
dd, pipe-to-shell, chmod 777 /,
write/edit /etc/ .ssh/,
download-then-execute chains
High (0.80, review): sudo, kill -9,
destructive git, DROP TABLE,
secrets, HTTP mutations, ssh/scp,
browser+data-export, transitive
install, control-plane mutation
Medium (0.70, review): content
ingestion, interpreter exec,
cloud CLI mutations, pkg install,
write_file, MCP tools, docker ops
Low (0.85, approve): read_file,
list_directory, search, recall,
tool_search, read_resource,
web_search, read-only bash
Default: medium, 0.50, review
end note
Judge --> Session : heuristic_verdicts[]
Session -> Session : attach _heuristic_verdict\nto each pending item
Session -> UI : SSE: approve_request\n{items: [{verdict: ...}],\n judge_pending: true}
note right
Heuristic verdict displayed
immediately as risk badge.
Spinner shown while LLM
judge evaluates.
end note
Session -> Storage : create_intent_verdict()\nfor each heuristic verdict
== Tier 2: LLM Judge (daemon thread, async) ==
Judge -> Judge : spawn daemon thread\n"intent-judge"
note over Judge, LLM
**Context preparation:**
1. FIFO-truncate conversation history
to max_context_ratio of context window
2. Append tool call details as user message
3. System prompt defines judge role + JSON schema
end note
loop up to 3 turns (timeout budget)
Judge -> LLM : create_completion(\nmodel, judge_messages,\ntools=[read_file, list_directory])
LLM --> Judge : CompletionResult
alt tool_calls present (turn < 3)
Judge -> Judge : _exec_read_only_tool()
note right
**Security hardening:**
Blocked: /etc/, /root/,
/proc/, /sys/, /dev/,
.ssh, .gnupg, .aws,
*.pem, *.key, *.p12
File cap: 32KB
Dir cap: 200 entries
end note
Judge -> FS : read_file / list_directory
FS --> Judge : file contents
Judge -> Judge : append tool result\nto judge_messages
else text response (final verdict)
Judge -> Judge : _parse_verdict()
note right
**4-stage JSON parsing:**
1. Direct JSON.loads
2. Markdown code block
3. Brace-counting
4. Regex field extraction
end note
end
end
== Tier 3: Arbitration ==
Judge -> Judge : compare confidence:\nLLM vs heuristic
note right
Only deliver LLM verdict
if confidence > heuristic.
Otherwise heuristic stands.
end note
alt LLM confidence > heuristic confidence
Judge -> Session : callback(llm_verdict)
Session -> UI : SSE: intent_verdict\n{tier: "llm", ...}
note right
UI replaces heuristic badge
with LLM verdict. Spinner
resolves to final assessment.
end note
Session -> Storage : create_intent_verdict()\nfor LLM verdict
end
== User Decision ==
UI -> Session : resolve_approval(\napproved, feedback)
Session -> Storage : update_intent_verdict(\nverdict_id, user_decision)
note right
All tracked verdicts
(heuristic + LLM) updated
with "approved" or "denied".
Swap-and-clear avoids racing
with daemon judge thread.
end note
== Tool Execution ==
Session -> Session : _execute_tools()
note right
Tools execute with
user approval.
end note
== Output Guard (synchronous, time-budgeted) ==
Session -> Session : _evaluate_output()\nfor each tool result
note right
**Priority-ordered checks (5s budget):**
P1: Prompt injection (role injection,
override phrases, instruction tags)
P2: Credential leakage (API keys,
PEM blocks, connection strings)
P3: Encoded payloads (data URIs,
hex shellcode)
P4: Adversarial URLs (cloud metadata,
credential query params)
P5: System info disclosure (private
IPs, sensitive paths)
Annotates + optionally redacts.
Does NOT gate.
end note
alt output_warning flags detected
Session -> UI : SSE: output_warning\n{call_id, risk_level, flags,\nfunc_name, redacted}
note right
Credential values replaced
with [REDACTED:<type>] before
output enters conversation.
sanitized text excluded from
SSE payload (defense in depth).
end note
UI -> Storage : record_output_assessment()\nfire-and-forget persistence
note right
Stored: flags, risk_level,
annotations, output_length,
redacted (bool). Raw tool
output is never stored.
end note
end
== Lifecycle ==
note over Session, Judge
**Lazy initialization:**
IntentJudge created on first approval if judge_config.enabled.
Re-uses session's provider/client by default (self-consistency).
Cross-model: separate provider/client from [judge] config.
**Sub-agent exemption:**
Plan agent and task agent skip intent validation entirely.
**Output guard:**
Runs when judge_config.output_guard is true (default).
Credential redaction when judge_config.redact_secrets is true.
**Storage:**
intent_verdicts table (migration 012), output_assessments table
(migration 022). Both queryable via admin API endpoints
(requires admin.judge permission). Skills store risk_level,
scan_report, scan_version for install-time risk assessment.
end note
@enduml
+159
View File
@@ -0,0 +1,159 @@
@startuml
!theme plain
title Turnstone — Structured Memory Architecture
skinparam participant {
BackgroundColor<<session>> #C8E6C9
BackgroundColor<<facade>> #FFE0B2
BackgroundColor<<storage>> #B3E5FC
BackgroundColor<<api>> #E8EAF6
BackgroundColor<<sdk>> #F5F5F5
}
participant "ChatSession\n(session.py)" as Session <<session>>
participant "MemoryFacade\n(memory.py)" as Facade <<facade>>
participant "MemoryRelevance\n(memory_relevance.py)" as Relevance <<facade>>
participant "StorageBackend\n(SQLite)" as Storage <<storage>>
participant "Server API\n(server.py)" as API <<api>>
participant "Console Admin\n(console/server.py)" as Admin <<api>>
participant "SDK Client\n(sdk/)" as SDK <<sdk>>
== Phase 1: Tool Path (session.send) ==
Session -> Session : _prepare_tool_calls()\nparse memory(action=...)
note right
Tool schema: 4 actions
save, search, delete, list
Auto-approved (no approval needed)
end note
Session -> Session : _exec_memory(item)
alt action = save
Session -> Facade : save_structured_memory(\nname, content, description,\nmem_type, scope, scope_id)
Facade -> Facade : normalize_key(name)
Facade -> Storage : create_structured_memory()
alt unique constraint violation
Storage --> Facade : IntegrityError
Facade -> Storage : get_structured_memory_by_name()
Storage --> Facade : existing row
Facade -> Storage : update_structured_memory()
end
Storage --> Facade : memory_id
Facade --> Session : (memory_id, old_content)
Session -> Session : _init_system_messages()\nrefresh BM25 context
end
alt action = search
Session -> Facade : search_structured_memories(\nquery, mem_type, scope,\nscope_id, limit)
Facade -> Storage : search_structured_memories()
Storage --> Session : matched rows
end
alt action = delete
Session -> Facade : delete_structured_memory(\nname, scope, scope_id)
Facade -> Storage : delete_structured_memory()
Storage --> Session : bool (existed)
Session -> Session : _init_system_messages()\nrefresh BM25 context
end
== Phase 2: BM25 Relevance Injection ==
Session -> Session : _init_system_messages()\nevery conversation turn
Session -> Session : _list_visible_memories(\nlimit=fetch_limit)
note right
**Scope resolution:**
1. global scope (always)
2. workstream scope (ws_id)
3. user scope (user_id, if auth)
Combined and deduplicated.
end note
Session -> Facade : list_structured_memories()\nper scope
Facade -> Storage : list_structured_memories()
Storage --> Session : up to fetch_limit rows
Session -> Relevance : extract_recent_context(\nmessages, max_messages=3)
Relevance --> Session : user text context
Session -> Relevance : score_memories(\nmemories, context,\nk=relevance_k)
note right
**BM25 scoring:**
Index over name + description
+ content[:200] for each memory.
Returns top-k by relevance.
Empty query returns most recent k.
end note
Relevance --> Session : top-k memories
Session -> Relevance : build_memory_context(\nrelevant_memories)
note right
Formats as XML block:
<memories>
<memory name="..." type="..."
scope="..." description="...">
content (max 500 chars)
</memory>
</memories>
end note
Relevance --> Session : XML string
Session -> Session : inject into\nsystem message
== Phase 3: Server API Path ==
SDK -> API : GET /v1/api/memories\n?type=project&limit=20
API -> Facade : list_structured_memories()
Facade -> Storage : list_structured_memories()
Storage --> API : rows
API --> SDK : {"memories": [...], "total": N}
SDK -> API : POST /v1/api/memories\n{name, content, ...}
API -> API : validate type, scope,\nname length, content length
API -> Facade : save_structured_memory()
Facade -> Storage : create / update
Storage --> API : memory row
API --> SDK : 201 (created) / 200 (updated)
SDK -> API : POST /v1/api/memories/search\n{query, type, ...}
API -> Facade : search_structured_memories()
Facade -> Storage : search_structured_memories()
Storage --> API : matched rows
API --> SDK : {"memories": [...], "total": N}
SDK -> API : DELETE /v1/api/memories/{name}\n?scope=global
API -> Facade : delete_structured_memory()
Facade -> Storage : delete row
API --> SDK : {"status": "ok"}
== Phase 4: Console Admin Path ==
SDK -> Admin : GET /v1/api/admin/memories\n?type=&scope=&limit=
Admin -> Admin : require_permission(\n"admin.memories")
Admin -> Storage : list_structured_memories()
Storage --> Admin : rows
Admin --> SDK : {"memories": [...], "total": N}
SDK -> Admin : GET /v1/api/admin/memories/{id}
Admin -> Storage : get_structured_memory(id)
Storage --> Admin : memory row
Admin --> SDK : memory JSON
SDK -> Admin : DELETE /v1/api/admin/memories/{id}
Admin -> Storage : delete_structured_memory_by_id()
Admin -> Admin : record_audit(\n"memory.delete")
Admin --> SDK : {"status": "ok"}
== Configuration ==
note over Session, Relevance
**MemoryConfig** (from [memory] in config.toml):
relevance_k = 5 -- top-k memories per turn
fetch_limit = 50 -- max memories fetched for scoring
max_content = 32768 -- max content length per memory
nudge_cooldown = 300 -- seconds between metacognitive nudges
nudges = true -- enable/disable memory nudges
end note
@enduml
+151
View File
@@ -0,0 +1,151 @@
@startuml
!theme plain
title Turnstone — Settings Architecture
skinparam participant {
BackgroundColor<<session>> #C8E6C9
BackgroundColor<<config>> #FFE0B2
BackgroundColor<<storage>> #B3E5FC
BackgroundColor<<api>> #E8EAF6
BackgroundColor<<sdk>> #F5F5F5
}
participant "Server\n(main)" as Server <<session>>
participant "ConfigStore\n(config_store.py)" as Store <<config>>
participant "SettingsRegistry\n(settings_registry.py)" as Registry <<config>>
participant "StorageBackend\n(SQLite)" as Storage <<storage>>
participant "Console Admin\n(console/server.py)" as Admin <<api>>
participant "SDK Client\n(sdk/)" as SDK <<sdk>>
participant "ChatSession\n(session.py)" as Session <<session>>
== Phase 1: Server Startup ==
Server -> Server : parse_args()\nCLI flags override defaults
Server -> Server : init_storage()\nSQLite / PostgreSQL
Server -> Store ** : ConfigStore(storage, node_id)
Store -> Storage : get_system_settings_bulk(node_id)
note right
1. Load global settings (node_id="")
2. Overlay per-node settings
Returns {key: json_value} dict
end note
Storage --> Store : raw settings
Store -> Registry : deserialize_value(key, json)\nper entry
Registry --> Store : typed values
Store -> Store : swap _cache atomically\nincrement _version
Server -> Server : warn_migrated_settings()
note right
Scans config.toml for keys
now managed by ConfigStore.
Logs warning for each overlap.
end note
Server -> Server : session_factory captures\nConfigStore reference
== Phase 2: Settings Read (session creation) ==
Server -> Session : session_factory(ws_id)
Session -> Store : get("model.temperature")
Store -> Store : cache[key] lookup\n(lock-free)
alt key in cache
Store --> Session : stored value
else key not in cache
Store -> Registry : SETTINGS[key].default
Registry --> Store : default value
Store --> Session : default value
end
note right of Session
Settings are captured once
at workstream creation.
Not re-read on every turn.
end note
== Phase 3: Admin API — List / Schema ==
SDK -> Admin : GET /v1/api/admin/settings
Admin -> Admin : require_permission(\n"admin.settings")
Admin -> Store : all_effective()
Store -> Store : merge cache with\nregistry defaults
Store --> Admin : {key: effective_value}
Admin -> Registry : SETTINGS (metadata)
note right
Annotates each setting with:
type, default, description,
is_stored, is_secret, constraints,
changed_by, updated
end note
Admin --> SDK : {"settings": [...], "total": N}
SDK -> Admin : GET /v1/api/admin/settings/schema
Admin -> Admin : require_permission(\n"admin.settings")
Admin -> Registry : SETTINGS catalog
Admin --> SDK : {"settings": [...], "total": N}
== Phase 4: Admin API — Update ==
SDK -> Admin : PUT /v1/api/admin/settings/\nmodel.temperature\n{"value": 0.7}
Admin -> Admin : require_permission(\n"admin.settings")
Admin -> Registry : validate_key("model.temperature")
Registry --> Admin : SettingDef
alt is_secret == true
Admin --> SDK : 403 Forbidden
else
Admin -> Registry : validate_value(key, 0.7)
note right
Type coercion: float(0.7)
Range check: 0.0 <= 0.7 <= 2.0
Choices check: (none for this key)
end note
Registry --> Admin : typed value
Admin -> Store : set(key, 0.7, changed_by="admin")
Store -> Registry : serialize_value(0.7)\n=> "0.7"
Store -> Storage : upsert_system_setting(\nkey, "0.7", node_id, ...)
Storage --> Store : ok
Store -> Store : swap _cache atomically
Admin -> Admin : record_audit(\n"setting.update")
Admin --> SDK : {"key": "...", "value": 0.7,\n"previous": 0.5}
end
== Phase 5: Admin API — Delete (reset to default) ==
SDK -> Admin : DELETE /v1/api/admin/settings/\nmodel.temperature
Admin -> Admin : require_permission(\n"admin.settings")
Admin -> Store : delete("model.temperature")
Store -> Registry : validate_key(key)
Store -> Storage : delete_system_setting(key, node_id)
Storage --> Store : bool (existed)
Store -> Store : remove from cache,\nswap atomically
Admin -> Admin : record_audit(\n"setting.delete")
Admin --> SDK : {"status": "ok",\n"key": "...", "default": 0.5}
== Phase 6: Hot Reload ==
SDK -> Admin : POST /v1/api/_internal/\nconfig-reload
Admin -> Store : reload()
Store -> Storage : get_system_settings_bulk(node_id)
Storage --> Store : all settings
Store -> Store : rebuild cache,\nswap atomically,\nincrement _version
note right
Existing sessions: unchanged
(frozen at creation time).
New sessions: pick up
updated values immediately.
end note
Admin --> SDK : {"status": "ok"}
== Precedence Summary ==
note over Server, Registry
**Server entry point:**
CLI flag > ConfigStore (database) > registry default
**CLI entry point:**
CLI flag > config.toml > argparse default
**Bootstrap settings** (database, auth, server bind):
Always from config.toml / env vars — never in ConfigStore.
end note
@enduml
+147
View File
@@ -0,0 +1,147 @@
@startuml
!theme plain
title Turnstone — OIDC Authorization Code Flow with PKCE
skinparam participant {
BackgroundColor<<browser>> #E8EAF6
BackgroundColor<<server>> #FFE0B2
BackgroundColor<<storage>> #B3E5FC
BackgroundColor<<idp>> #C8E6C9
}
participant "Browser" as Browser <<browser>>
participant "Turnstone\n(Server / Console)" as Server <<server>>
database "SQLite /\nPostgreSQL" as DB <<storage>>
participant "Identity Provider\n(IdP)" as IdP <<idp>>
== Page Load ==
Browser -> Server : GET /v1/api/auth/status
Server --> Browser : {oidc_enabled: true,\noidc_provider_name: "...",\npassword_enabled: true}
note right of Browser
Login screen renders
"Continue with {provider_name}"
button alongside password form.
If password_enabled=false,
only the SSO button is shown.
end note
== Authorization Request ==
Browser -> Server : GET /v1/api/auth/oidc/authorize
Server -> Server : Generate state (random)\nnonce (random)\nPKCE code_verifier + code_challenge
Server -> DB : create_oidc_pending_state(\nstate, nonce, code_verifier, audience)
note right of DB
Stored with created_at timestamp.
Expires after 5 minutes.
end note
Server --> Browser : 302 Redirect to IdP\nauthorization_endpoint
Browser -> IdP : GET /authorize?\nresponse_type=code&\nclient_id=...&\nredirect_uri=...&\nscope=openid email profile&\nstate=...&nonce=...&\ncode_challenge=...&\ncode_challenge_method=S256
== User Authentication (at IdP) ==
IdP -> Browser : Login page (if no\nexisting IdP session)
Browser -> IdP : User authenticates\n(username/password, MFA, etc.)
IdP --> Browser : 302 Redirect to callback\n?code=AUTH_CODE&state=STATE
== Callback Processing ==
Browser -> Server : GET /v1/api/auth/oidc/callback\n?code=AUTH_CODE&state=STATE
Server -> Server : Rate limit check\n(5 per 5min per IP)
Server -> DB : cleanup_expired_oidc_states(300)
note right of DB
Lazy cleanup of states
older than 5 minutes.
end note
Server -> DB : pop_oidc_pending_state(state)
DB --> Server : {nonce, code_verifier, audience}
note right of Server
Atomic fetch-and-delete.
Returns None if state is
expired or unknown.
end note
== Token Exchange ==
Server -> IdP : POST /token\ngrant_type=authorization_code&\ncode=AUTH_CODE&\nclient_id=...&\nclient_secret=...&\ncode_verifier=...&\nredirect_uri=...
note right of Server
Client secret + PKCE verifier
sent server-side only.
Never exposed to browser.
end note
IdP --> Server : {id_token: "eyJ...",\naccess_token: "..."}
== ID Token Validation ==
Server -> IdP : Fetch JWKS public keys\n(cached at startup, refreshed\non-demand when unknown kid\nencountered — key rotation)
Server -> Server : Validate ID token:\n1. Verify signature (RS256/ES256)\n2. Check iss == configured issuer\n3. Check aud == client_id\n4. Check exp (not expired)\n5. Verify nonce matches
== User Provisioning ==
Server -> DB : get_oidc_identity(issuer, sub)
alt Existing identity found
DB --> Server : {user_id, ...}
Server -> DB : update_oidc_identity_login()\nupdate last_login timestamp
Server -> DB : get_user(user_id)
DB --> Server : user record
else New user (first login)
Server -> Server : Derive username from\npreferred_username / email
Server -> DB : create_user(user_id, username,\ndisplay_name, "!oidc")
note right of DB
Password hash set to sentinel
value "!oidc" — not a valid
bcrypt hash, so password login
is always rejected.
end note
Server -> DB : create_oidc_identity(\nissuer, sub, user_id, email)
end
opt Role mapping configured
Server -> Server : Read role_claim from ID token\nMap values via role_map
Server -> DB : Sync roles: add new,\nrevoke stale OIDC-assigned,\npreserve manually assigned
end
== Issue Turnstone JWT ==
Server -> Server : Load user permissions\nDerive scopes from permissions
Server -> Server : Create JWT (HS256)\nsub: user_id\nscopes: read,write,...\nsrc: "oidc"\naud: turnstone-server\nexp: +24h
Server --> Browser : 302 Redirect to /?oidc_success=1\nSet-Cookie: session=JWT\n(HttpOnly, SameSite=Lax, Secure)
== Browser Success Detection ==
Browser -> Browser : Detect ?oidc_success=1\nStrip param from URL\n(history.replaceState)
Browser -> Browser : Hide login overlay\nCall onLoginSuccess()
note right of Browser
Browser is now authenticated.
JWT cookie sent on all
subsequent requests.
end note
== Error Paths ==
note over Browser, IdP
**Error handling:**
- IdP returns error param → redirect to /?oidc_error=...
- State missing/expired → redirect to /?oidc_error=Login+session+expired
- Token exchange fails → redirect to /?oidc_error=...
- ID token validation fails → redirect to /?oidc_error=...
- No admin user exists → redirect to /?oidc_error=Initial+setup+required
- Rate limit exceeded → redirect to /?oidc_error=Too+many+login+attempts
All errors are shown as toast messages on the login screen.
end note
@enduml
@@ -0,0 +1,100 @@
@startuml
!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Component.puml
LAYOUT_LEFT_RIGHT()
title Skills Discovery & Runtime Architecture
skinparam backgroundColor #1e1e2e
skinparam defaultFontColor #cdd6f4
skinparam defaultFontName "JetBrains Mono"
skinparam arrowColor #89b4fa
skinparam rectangleBorderColor #585b70
skinparam rectangleBackgroundColor #313244
skinparam noteBorderColor #585b70
skinparam noteBackgroundColor #45475a
skinparam packageBorderColor #585b70
package "External Sources" as ext #181825 {
rectangle "skills.sh\nRegistry" as skillssh
rectangle "GitHub\nRepositories" as github
}
package "Console Server" as console #181825 {
rectangle "admin_skill_discover\nGET /v1/api/admin/skills/discover" as discover
rectangle "admin_skill_install\nPOST /v1/api/admin/skills/install" as install
rectangle "_get_discovery_url\nsettings fallback" as settings
}
package "Core Modules" as core #181825 {
rectangle "SkillsShClient\nskill_sources.py" as client
rectangle "fetch_skill_from_github\nskill_sources.py" as fetcher
rectangle "parse_skill_md\nskill_parser.py" as parser
rectangle "scan_skill_content\nstorage/_utils.py" as scanner
}
package "Session Runtime" as runtime #181825 {
rectangle "skill tool\nsession.py" as loadtool
rectangle "set_skill()\nsession.py" as setskill
rectangle "_load_skills()\nsession.py" as loadskills
}
package "Storage" as storage #181825 {
rectangle "prompt_templates\n(skills)" as skills_table
rectangle "skill_resources\n(bundled files)" as resources_table
rectangle "system_settings\n(discovery_url)" as settings_table
}
package "Admin UI" as ui #181825 {
rectangle "Skills Tab\nInstalled / Discover pill" as pill
rectangle "Discovery View\nsearch + cards" as discoverui
rectangle "GitHub Import\nmodal" as importui
}
' External discovery flow
discover --> settings : resolve URL
settings --> settings_table : DB -> config -> default
discover --> client : search(query)
client --> skillssh : GET /api/search
install --> client : resolve_github_url()
client --> skillssh : GET /api/skills/{id}
install --> fetcher : fetch SKILL.md + resources
fetcher --> github : raw.githubusercontent.com
fetcher --> github : api.github.com/git/trees
fetcher --> parser : parse frontmatter
install --> scanner : auto-scan on create
install --> skills_table : create_prompt_template
install --> resources_table : create_skill_resource
' Runtime skill loading flow
loadtool --> skills_table : search (BM25 ranking)
loadtool --> setskill : load (name)
setskill --> loadskills : reload + reinit system messages
loadskills --> skills_table : get_skill_by_name
' UI flow
pill --> discoverui : switch view
discoverui --> discover : authFetch()
importui --> install : POST (github source)
' Annotations
note right of parser
YAML frontmatter -> ParsedSkill
allowed-tools (standard) -> allowed_tools (internal)
Anthropic + Hermes tag formats
Name validation (lowercase+hyphens)
end note
note right of loadtool
search: auto-approved (read-only)
load: requires user approval
Main session only (no sub-agents)
end note
note right of scanner
4 risk axes (content, supply chain,
vulnerability, capability)
Auto-triggers on create/update
end note
@enduml
@@ -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
+132 -171
View File
@@ -34,253 +34,214 @@
<text x="600" y="54" text-anchor="middle" fill="#8b949e" font-size="11" letter-spacing="1">SYSTEM ARCHITECTURE</text>
<!-- ==================== COLUMN HEADERS ==================== -->
<text x="90" y="86" text-anchor="middle" fill="#58a6ff" font-size="9" font-weight="600" letter-spacing="2">CLIENTS</text>
<text x="276" y="86" text-anchor="middle" fill="#3fb950" font-size="9" font-weight="600" letter-spacing="2">GATEWAYS</text>
<text x="480" y="86" text-anchor="middle" fill="#f0883e" font-size="9" font-weight="600" letter-spacing="2">MESSAGE QUEUE</text>
<text x="700" y="86" text-anchor="middle" fill="#f47067" font-size="9" font-weight="600" letter-spacing="2">CLUSTER NODES</text>
<text x="940" y="86" text-anchor="middle" fill="#f778ba" font-size="9" font-weight="600" letter-spacing="2">LLM PROVIDERS</text>
<text x="110" y="86" text-anchor="middle" fill="#58a6ff" font-size="9" font-weight="600" letter-spacing="2">CLIENTS</text>
<text x="380" y="86" text-anchor="middle" fill="#3fb950" font-size="9" font-weight="600" letter-spacing="2">CONSOLE ROUTER</text>
<text x="700" y="86" text-anchor="middle" fill="#f47067" font-size="9" font-weight="600" letter-spacing="2">SERVER NODES</text>
<text x="1010" y="86" text-anchor="middle" fill="#f778ba" font-size="9" font-weight="600" letter-spacing="2">LLM PROVIDERS</text>
<!-- ==================== CLIENT BOXES ==================== -->
<!-- CLI -->
<g filter="url(#shadow)">
<rect x="30" y="108" width="120" height="46" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
<rect x="30" y="108" width="120" height="5" rx="5" fill="#58a6ff"/>
<rect x="30" y="108" width="120" height="5" fill="#58a6ff"/>
<rect x="30" y="111" width="120" height="2" fill="#161b22"/>
<text x="90" y="130" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">CLI</text>
<text x="90" y="145" text-anchor="middle" fill="#8b949e" font-size="9">terminal REPL</text>
<rect x="40" y="108" width="140" height="46" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
<rect x="40" y="108" width="140" height="5" rx="5" fill="#58a6ff"/>
<rect x="40" y="108" width="140" height="5" fill="#58a6ff"/>
<rect x="40" y="111" width="140" height="2" fill="#161b22"/>
<text x="110" y="130" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">CLI</text>
<text x="110" y="145" text-anchor="middle" fill="#8b949e" font-size="9">terminal REPL</text>
</g>
<!-- Browser UI -->
<g filter="url(#shadow)">
<rect x="30" y="174" width="120" height="46" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
<rect x="30" y="174" width="120" height="5" rx="5" fill="#58a6ff"/>
<rect x="30" y="174" width="120" height="5" fill="#58a6ff"/>
<rect x="30" y="177" width="120" height="2" fill="#161b22"/>
<text x="90" y="196" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">Browser UI</text>
<text x="90" y="211" text-anchor="middle" fill="#8b949e" font-size="9">HTTP + SSE</text>
<rect x="40" y="174" width="140" height="46" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
<rect x="40" y="174" width="140" height="5" rx="5" fill="#58a6ff"/>
<rect x="40" y="174" width="140" height="5" fill="#58a6ff"/>
<rect x="40" y="177" width="140" height="2" fill="#161b22"/>
<text x="110" y="196" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">Browser UI</text>
<text x="110" y="211" text-anchor="middle" fill="#8b949e" font-size="9">HTTP + SSE</text>
</g>
<!-- SDK / API -->
<g filter="url(#shadow)">
<rect x="30" y="244" width="120" height="46" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
<rect x="30" y="244" width="120" height="5" rx="5" fill="#58a6ff"/>
<rect x="30" y="244" width="120" height="5" fill="#58a6ff"/>
<rect x="30" y="247" width="120" height="2" fill="#161b22"/>
<text x="90" y="266" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">SDK / API</text>
<text x="90" y="281" text-anchor="middle" fill="#8b949e" font-size="9">programmatic</text>
</g>
<!-- Discord / Slack -->
<g filter="url(#shadow)">
<rect x="30" y="314" width="120" height="46" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
<rect x="30" y="314" width="120" height="5" rx="5" fill="#58a6ff"/>
<rect x="30" y="314" width="120" height="5" fill="#58a6ff"/>
<rect x="30" y="317" width="120" height="2" fill="#161b22"/>
<text x="90" y="336" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">Discord / Slack</text>
<text x="90" y="351" text-anchor="middle" fill="#8b949e" font-size="9">chat platforms</text>
</g>
<!-- ==================== GATEWAY BOXES ==================== -->
<!-- Console -->
<g filter="url(#shadow)">
<rect x="216" y="118" width="120" height="58" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
<rect x="216" y="118" width="120" height="5" rx="5" fill="#3fb950"/>
<rect x="216" y="118" width="120" height="5" fill="#3fb950"/>
<rect x="216" y="121" width="120" height="2" fill="#161b22"/>
<text x="276" y="142" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">Console</text>
<text x="276" y="157" text-anchor="middle" fill="#8b949e" font-size="9">dashboard + proxy</text>
<text x="276" y="169" text-anchor="middle" fill="#8b949e" font-size="9">cluster management</text>
<rect x="40" y="244" width="140" height="46" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
<rect x="40" y="244" width="140" height="5" rx="5" fill="#58a6ff"/>
<rect x="40" y="244" width="140" height="5" fill="#58a6ff"/>
<rect x="40" y="247" width="140" height="2" fill="#161b22"/>
<text x="110" y="266" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">SDK / API</text>
<text x="110" y="281" text-anchor="middle" fill="#8b949e" font-size="9">programmatic</text>
</g>
<!-- Channel Gateway -->
<g filter="url(#shadow)">
<rect x="216" y="292" width="120" height="58" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
<rect x="216" y="292" width="120" height="5" rx="5" fill="#3fb950"/>
<rect x="216" y="292" width="120" height="5" fill="#3fb950"/>
<rect x="216" y="295" width="120" height="2" fill="#161b22"/>
<text x="276" y="316" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">Channel Gateway</text>
<text x="276" y="331" text-anchor="middle" fill="#8b949e" font-size="9">platform adapter</text>
<text x="276" y="343" text-anchor="middle" fill="#8b949e" font-size="9">Discord, Slack, ...</text>
<rect x="40" y="314" width="140" height="46" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
<rect x="40" y="314" width="140" height="5" rx="5" fill="#58a6ff"/>
<rect x="40" y="314" width="140" height="5" fill="#58a6ff"/>
<rect x="40" y="317" width="140" height="2" fill="#161b22"/>
<text x="110" y="336" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">Channel Gateway</text>
<text x="110" y="351" text-anchor="middle" fill="#8b949e" font-size="9">Discord, Slack, ...</text>
</g>
<!-- ==================== REDIS MQ ==================== -->
<!-- ==================== CONSOLE ROUTER ==================== -->
<g filter="url(#shadow)">
<rect x="420" y="168" width="120" height="132" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
<rect x="420" y="168" width="120" height="5" rx="5" fill="#f0883e"/>
<rect x="420" y="168" width="120" height="5" fill="#f0883e"/>
<rect x="420" y="171" width="120" height="2" fill="#161b22"/>
<text x="480" y="198" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">Redis MQ</text>
<line x1="438" y1="210" x2="522" y2="210" stroke="#30363d" stroke-width="1"/>
<text x="480" y="228" text-anchor="middle" fill="#8b949e" font-size="9">inbound queues</text>
<text x="480" y="243" text-anchor="middle" fill="#8b949e" font-size="9">event pub/sub</text>
<text x="480" y="258" text-anchor="middle" fill="#8b949e" font-size="9">node heartbeats</text>
<text x="480" y="273" text-anchor="middle" fill="#8b949e" font-size="9">workstream routing</text>
<text x="480" y="288" text-anchor="middle" fill="#8b949e" font-size="9">cluster state</text>
<rect x="300" y="148" width="160" height="170" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
<rect x="300" y="148" width="160" height="5" rx="5" fill="#3fb950"/>
<rect x="300" y="148" width="160" height="5" fill="#3fb950"/>
<rect x="300" y="151" width="160" height="2" fill="#161b22"/>
<text x="380" y="178" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">Console</text>
<line x1="318" y1="190" x2="442" y2="190" stroke="#30363d" stroke-width="1"/>
<text x="380" y="208" text-anchor="middle" fill="#8b949e" font-size="9">hash-ring router</text>
<text x="380" y="223" text-anchor="middle" fill="#8b949e" font-size="9">cluster dashboard</text>
<text x="380" y="238" text-anchor="middle" fill="#8b949e" font-size="9">reverse proxy</text>
<line x1="318" y1="250" x2="442" y2="250" stroke="#30363d" stroke-width="1"/>
<text x="380" y="268" text-anchor="middle" fill="#484f58" font-size="8">control plane:</text>
<text x="380" y="282" text-anchor="middle" fill="#484f58" font-size="8">create / send / approve</text>
<text x="380" y="296" text-anchor="middle" fill="#484f58" font-size="8">cancel / command / close</text>
<text x="380" y="310" text-anchor="middle" fill="#484f58" font-size="8">port 8090</text>
</g>
<!-- ==================== CLUSTER NODES ==================== -->
<!-- ==================== SERVER NODES ==================== -->
<!-- Cluster outline -->
<rect x="598" y="100" width="204" height="310" rx="8" fill="none" stroke="#30363d" stroke-width="1" stroke-dasharray="4,3"/>
<rect x="570" y="100" width="260" height="310" rx="8" fill="none" stroke="#30363d" stroke-width="1" stroke-dasharray="4,3"/>
<text x="700" y="422" text-anchor="middle" fill="#30363d" font-size="9" letter-spacing="1">CLUSTER</text>
<!-- Node A -->
<g filter="url(#shadow)">
<rect x="614" y="118" width="170" height="100" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
<rect x="614" y="118" width="170" height="5" rx="5" fill="#f47067"/>
<rect x="614" y="118" width="170" height="5" fill="#f47067"/>
<rect x="614" y="121" width="170" height="2" fill="#161b22"/>
<text x="699" y="142" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">Node A</text>
<line x1="632" y1="152" x2="766" y2="152" stroke="#30363d" stroke-width="1"/>
<!-- Bridge -->
<rect x="626" y="162" width="70" height="28" rx="3" fill="#1c2128" stroke="#30363d" stroke-width="1"/>
<text x="661" y="180" text-anchor="middle" fill="#8b949e" font-size="9">bridge</text>
<!-- Server -->
<rect x="704" y="162" width="70" height="28" rx="3" fill="#1c2128" stroke="#30363d" stroke-width="1"/>
<text x="739" y="180" text-anchor="middle" fill="#8b949e" font-size="9">server</text>
<!-- Arrow bridge to server -->
<line x1="696" y1="176" x2="702" y2="176" stroke="#484f58" stroke-width="1" marker-end="url(#arrow)"/>
<rect x="590" y="118" width="220" height="100" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
<rect x="590" y="118" width="220" height="5" rx="5" fill="#f47067"/>
<rect x="590" y="118" width="220" height="5" fill="#f47067"/>
<rect x="590" y="121" width="220" height="2" fill="#161b22"/>
<text x="700" y="142" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">Server Node A</text>
<line x1="608" y1="152" x2="792" y2="152" stroke="#30363d" stroke-width="1"/>
<!-- Server process -->
<rect x="608" y="162" width="184" height="28" rx="3" fill="#1c2128" stroke="#30363d" stroke-width="1"/>
<text x="700" y="180" text-anchor="middle" fill="#8b949e" font-size="9">turnstone-server :8080</text>
<!-- Tools label -->
<text x="699" y="206" text-anchor="middle" fill="#484f58" font-size="8">14 tools + MCP</text>
<text x="700" y="206" text-anchor="middle" fill="#484f58" font-size="8">19 tools + MCP</text>
</g>
<!-- Node B -->
<g filter="url(#shadow)">
<rect x="614" y="238" width="170" height="100" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
<rect x="614" y="238" width="170" height="5" rx="5" fill="#f47067"/>
<rect x="614" y="238" width="170" height="5" fill="#f47067"/>
<rect x="614" y="241" width="170" height="2" fill="#161b22"/>
<text x="699" y="262" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">Node B</text>
<line x1="632" y1="272" x2="766" y2="272" stroke="#30363d" stroke-width="1"/>
<!-- Bridge -->
<rect x="626" y="282" width="70" height="28" rx="3" fill="#1c2128" stroke="#30363d" stroke-width="1"/>
<text x="661" y="300" text-anchor="middle" fill="#8b949e" font-size="9">bridge</text>
<!-- Server -->
<rect x="704" y="282" width="70" height="28" rx="3" fill="#1c2128" stroke="#30363d" stroke-width="1"/>
<text x="739" y="300" text-anchor="middle" fill="#8b949e" font-size="9">server</text>
<!-- Arrow bridge to server -->
<line x1="696" y1="296" x2="702" y2="296" stroke="#484f58" stroke-width="1" marker-end="url(#arrow)"/>
<rect x="590" y="238" width="220" height="100" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
<rect x="590" y="238" width="220" height="5" rx="5" fill="#f47067"/>
<rect x="590" y="238" width="220" height="5" fill="#f47067"/>
<rect x="590" y="241" width="220" height="2" fill="#161b22"/>
<text x="700" y="262" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">Server Node B</text>
<line x1="608" y1="272" x2="792" y2="272" stroke="#30363d" stroke-width="1"/>
<!-- Server process -->
<rect x="608" y="282" width="184" height="28" rx="3" fill="#1c2128" stroke="#30363d" stroke-width="1"/>
<text x="700" y="300" text-anchor="middle" fill="#8b949e" font-size="9">turnstone-server :8080</text>
<!-- Tools label -->
<text x="699" y="326" text-anchor="middle" fill="#484f58" font-size="8">14 tools + MCP</text>
<text x="700" y="326" text-anchor="middle" fill="#484f58" font-size="8">19 tools + MCP</text>
</g>
<!-- ==================== LLM PROVIDERS ==================== -->
<!-- OpenAI -->
<g filter="url(#shadow)">
<rect x="870" y="130" width="140" height="46" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
<rect x="870" y="130" width="140" height="5" rx="5" fill="#f778ba"/>
<rect x="870" y="130" width="140" height="5" fill="#f778ba"/>
<rect x="870" y="133" width="140" height="2" fill="#161b22"/>
<text x="940" y="153" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">OpenAI</text>
<text x="940" y="167" text-anchor="middle" fill="#8b949e" font-size="9">GPT-5, o-series</text>
<rect x="930" y="130" width="160" height="46" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
<rect x="930" y="130" width="160" height="5" rx="5" fill="#f778ba"/>
<rect x="930" y="130" width="160" height="5" fill="#f778ba"/>
<rect x="930" y="133" width="160" height="2" fill="#161b22"/>
<text x="1010" y="153" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">OpenAI</text>
<text x="1010" y="167" text-anchor="middle" fill="#8b949e" font-size="9">GPT-5, o-series</text>
</g>
<!-- Anthropic -->
<g filter="url(#shadow)">
<rect x="870" y="196" width="140" height="46" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
<rect x="870" y="196" width="140" height="5" rx="5" fill="#f778ba"/>
<rect x="870" y="196" width="140" height="5" fill="#f778ba"/>
<rect x="870" y="199" width="140" height="2" fill="#161b22"/>
<text x="940" y="219" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">Anthropic</text>
<text x="940" y="233" text-anchor="middle" fill="#8b949e" font-size="9">Claude 4.5 / 4.6</text>
<rect x="930" y="196" width="160" height="46" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
<rect x="930" y="196" width="160" height="5" rx="5" fill="#f778ba"/>
<rect x="930" y="196" width="160" height="5" fill="#f778ba"/>
<rect x="930" y="199" width="160" height="2" fill="#161b22"/>
<text x="1010" y="219" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">Anthropic</text>
<text x="1010" y="233" text-anchor="middle" fill="#8b949e" font-size="9">Claude 4.5 / 4.6</text>
</g>
<!-- Local / vLLM -->
<g filter="url(#shadow)">
<rect x="870" y="262" width="140" height="46" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
<rect x="870" y="262" width="140" height="5" rx="5" fill="#f778ba"/>
<rect x="870" y="262" width="140" height="5" fill="#f778ba"/>
<rect x="870" y="265" width="140" height="2" fill="#161b22"/>
<text x="940" y="285" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">Local / vLLM</text>
<text x="940" y="299" text-anchor="middle" fill="#8b949e" font-size="9">llama.cpp, NIM</text>
<rect x="930" y="262" width="160" height="46" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
<rect x="930" y="262" width="160" height="5" rx="5" fill="#f778ba"/>
<rect x="930" y="262" width="160" height="5" fill="#f778ba"/>
<rect x="930" y="265" width="160" height="2" fill="#161b22"/>
<text x="1010" y="285" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">Local / vLLM</text>
<text x="1010" y="299" text-anchor="middle" fill="#8b949e" font-size="9">llama.cpp, NIM</text>
</g>
<!-- ==================== STORAGE ==================== -->
<g filter="url(#shadow)">
<rect x="614" y="450" width="170" height="52" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
<rect x="614" y="450" width="170" height="5" rx="5" fill="#bc8cff"/>
<rect x="614" y="450" width="170" height="5" fill="#bc8cff"/>
<rect x="614" y="453" width="170" height="2" fill="#161b22"/>
<text x="699" y="476" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">PostgreSQL / SQLite</text>
<text x="699" y="492" text-anchor="middle" fill="#8b949e" font-size="9">conversations, memory, auth</text>
<rect x="590" y="450" width="220" height="52" rx="5" fill="#161b22" stroke="#30363d" stroke-width="1"/>
<rect x="590" y="450" width="220" height="5" rx="5" fill="#bc8cff"/>
<rect x="590" y="450" width="220" height="5" fill="#bc8cff"/>
<rect x="590" y="453" width="220" height="2" fill="#161b22"/>
<text x="700" y="476" text-anchor="middle" fill="#e6edf3" font-size="11" font-weight="600">PostgreSQL / SQLite</text>
<text x="700" y="492" text-anchor="middle" fill="#8b949e" font-size="9">conversations, memory, auth</text>
</g>
<text x="699" y="444" text-anchor="middle" fill="#bc8cff" font-size="9" font-weight="600" letter-spacing="2">STORAGE</text>
<text x="700" y="444" text-anchor="middle" fill="#bc8cff" font-size="9" font-weight="600" letter-spacing="2">STORAGE</text>
<!-- ==================== CONNECTION LINES ==================== -->
<!-- CLIENT -> GATEWAY connections -->
<!-- CLIENT -> CONSOLE connections (control plane) -->
<!-- Browser -> Console -->
<line x1="150" y1="197" x2="214" y2="155" stroke="#58a6ff" stroke-width="1.2" stroke-opacity="0.6" marker-end="url(#arrow-blue)"/>
<!-- Discord -> Channel -->
<line x1="150" y1="337" x2="214" y2="325" stroke="#58a6ff" stroke-width="1.2" stroke-opacity="0.6" marker-end="url(#arrow-blue)"/>
<line x1="180" y1="197" x2="298" y2="210" stroke="#58a6ff" stroke-width="1.2" stroke-opacity="0.6" marker-end="url(#arrow-blue)"/>
<!-- Channel -> Console -->
<line x1="180" y1="337" x2="298" y2="290" stroke="#58a6ff" stroke-width="1.2" stroke-opacity="0.6" marker-end="url(#arrow-blue)"/>
<!-- SDK -> Console -->
<line x1="180" y1="267" x2="298" y2="248" stroke="#58a6ff" stroke-width="1.2" stroke-opacity="0.6" marker-end="url(#arrow-blue)"/>
<text x="240" y="238" fill="#484f58" font-size="8" text-anchor="middle">HTTP</text>
<!-- CLI -> direct to Node A server (top path, curved) -->
<path d="M 150 131 C 200 131, 200 100, 400 100 L 400 100 C 500 100, 570 140, 612 168" stroke="#58a6ff" stroke-width="1.2" stroke-opacity="0.5" fill="none" stroke-dasharray="6,3" marker-end="url(#arrow-blue)"/>
<text x="370" y="96" fill="#484f58" font-size="8" text-anchor="middle">direct</text>
<!-- CLI -> direct to Node A (single-node mode, above everything) -->
<path d="M 180 120 L 588 120" stroke="#58a6ff" stroke-width="1.2" stroke-opacity="0.4" fill="none" stroke-dasharray="6,3" marker-end="url(#arrow-blue)"/>
<text x="390" y="114" fill="#484f58" font-size="8" text-anchor="middle">direct (single-node)</text>
<!-- SDK -> Redis (direct push) -->
<line x1="150" y1="267" x2="418" y2="240" stroke="#58a6ff" stroke-width="1.2" stroke-opacity="0.6" marker-end="url(#arrow-blue)"/>
<!-- CONSOLE -> NODE connections (proxy) -->
<!-- Console -> Node A -->
<line x1="460" y1="200" x2="588" y2="176" stroke="#3fb950" stroke-width="1.2" stroke-opacity="0.6" marker-end="url(#arrow-green)"/>
<!-- Console -> Node B -->
<line x1="460" y1="260" x2="588" y2="296" stroke="#3fb950" stroke-width="1.2" stroke-opacity="0.6" marker-end="url(#arrow-green)"/>
<text x="520" y="222" fill="#484f58" font-size="8" text-anchor="middle">proxy</text>
<!-- GATEWAY -> REDIS connections -->
<!-- Console -> Redis -->
<line x1="336" y1="160" x2="418" y2="200" stroke="#3fb950" stroke-width="1.2" stroke-opacity="0.6" marker-end="url(#arrow-green)"/>
<!-- Channel -> Redis -->
<line x1="336" y1="318" x2="418" y2="272" stroke="#3fb950" stroke-width="1.2" stroke-opacity="0.6" marker-end="url(#arrow-green)"/>
<!-- REDIS -> NODE connections -->
<!-- Redis -> Node A bridge -->
<line x1="540" y1="210" x2="624" y2="176" stroke="#f0883e" stroke-width="1.2" stroke-opacity="0.6" marker-end="url(#arrow-orange)"/>
<!-- Redis -> Node B bridge -->
<line x1="540" y1="260" x2="624" y2="296" stroke="#f0883e" stroke-width="1.2" stroke-opacity="0.6" marker-end="url(#arrow-orange)"/>
<!-- Console -> Node (proxy, dashed) -->
<path d="M 336 147 C 380 130, 500 108, 612 145" stroke="#3fb950" stroke-width="1" stroke-opacity="0.4" fill="none" stroke-dasharray="4,3" marker-end="url(#arrow-green)"/>
<text x="468" y="120" fill="#484f58" font-size="8" text-anchor="middle">proxy</text>
<!-- CLIENT -> NODE direct SSE (data plane, below console) -->
<!-- Browser -> Node A SSE (arc below console) -->
<path d="M 180 205 C 240 370, 450 380, 588 330" stroke="#58a6ff" stroke-width="1" stroke-opacity="0.3" fill="none" stroke-dasharray="4,3" marker-end="url(#arrow-blue)"/>
<text x="340" y="378" fill="#484f58" font-size="8" text-anchor="middle">SSE (data plane)</text>
<!-- NODE -> LLM connections -->
<!-- Node A -> LLM providers -->
<line x1="784" y1="168" x2="868" y2="155" stroke="#f47067" stroke-width="1.2" stroke-opacity="0.5" marker-end="url(#arrow-coral)"/>
<line x1="784" y1="176" x2="868" y2="219" stroke="#f47067" stroke-width="1.2" stroke-opacity="0.3"/>
<line x1="784" y1="180" x2="868" y2="282" stroke="#f47067" stroke-width="1.2" stroke-opacity="0.2"/>
<line x1="810" y1="168" x2="928" y2="155" stroke="#f47067" stroke-width="1.2" stroke-opacity="0.5" marker-end="url(#arrow-coral)"/>
<line x1="810" y1="176" x2="928" y2="219" stroke="#f47067" stroke-width="1.2" stroke-opacity="0.3"/>
<line x1="810" y1="180" x2="928" y2="282" stroke="#f47067" stroke-width="1.2" stroke-opacity="0.2"/>
<!-- Node B -> LLM providers -->
<line x1="784" y1="288" x2="868" y2="163" stroke="#f47067" stroke-width="1.2" stroke-opacity="0.2"/>
<line x1="784" y1="296" x2="868" y2="222" stroke="#f47067" stroke-width="1.2" stroke-opacity="0.3"/>
<line x1="784" y1="300" x2="868" y2="288" stroke="#f47067" stroke-width="1.2" stroke-opacity="0.5" marker-end="url(#arrow-coral)"/>
<line x1="810" y1="288" x2="928" y2="163" stroke="#f47067" stroke-width="1.2" stroke-opacity="0.2"/>
<line x1="810" y1="296" x2="928" y2="222" stroke="#f47067" stroke-width="1.2" stroke-opacity="0.3"/>
<line x1="810" y1="300" x2="928" y2="288" stroke="#f47067" stroke-width="1.2" stroke-opacity="0.5" marker-end="url(#arrow-coral)"/>
<!-- NODE -> STORAGE connections -->
<line x1="680" y1="338" x2="680" y2="448" stroke="#bc8cff" stroke-width="1.2" stroke-opacity="0.4" stroke-dasharray="4,3" marker-end="url(#arrow-muted)"/>
<line x1="718" y1="218" x2="718" y2="236" stroke="#484f58" stroke-width="1" stroke-opacity="0.3" stroke-dasharray="2,2"/>
<!-- Extensibility hint -->
<text x="699" y="392" text-anchor="middle" fill="#30363d" font-size="10">...</text>
<!-- Event flow: Bridges -> Redis (dashed, bidirectional feel) -->
<line x1="624" y1="186" x2="542" y2="220" stroke="#f0883e" stroke-width="1" stroke-opacity="0.3" stroke-dasharray="3,3"/>
<line x1="624" y1="286" x2="542" y2="250" stroke="#f0883e" stroke-width="1" stroke-opacity="0.3" stroke-dasharray="3,3"/>
<text x="574" y="242" fill="#484f58" font-size="7" text-anchor="middle">events</text>
<text x="700" y="392" text-anchor="middle" fill="#30363d" font-size="10">...</text>
<!-- ==================== FLOW LABELS ==================== -->
<!-- Interactive flow label -->
<!-- Direct / single-node flow label -->
<rect x="30" y="395" width="10" height="10" rx="2" fill="none" stroke="#58a6ff" stroke-width="1.5" stroke-dasharray="3,2"/>
<text x="46" y="404" fill="#8b949e" font-size="9">interactive (direct)</text>
<text x="46" y="404" fill="#8b949e" font-size="9">direct (single-node / SSE)</text>
<!-- Queue flow label -->
<rect x="160" y="395" width="10" height="10" rx="2" fill="none" stroke="#f0883e" stroke-width="1.5"/>
<text x="176" y="404" fill="#8b949e" font-size="9">queue-driven</text>
<!-- Control plane label -->
<rect x="200" y="395" width="10" height="10" rx="2" fill="none" stroke="#58a6ff" stroke-width="1.5"/>
<text x="216" y="404" fill="#8b949e" font-size="9">control plane (HTTP)</text>
<!-- Proxy/event label -->
<rect x="275" y="395" width="10" height="10" rx="2" fill="none" stroke="#3fb950" stroke-width="1.5" stroke-dasharray="3,2"/>
<text x="291" y="404" fill="#8b949e" font-size="9">proxy / events</text>
<!-- Proxy label -->
<rect x="340" y="395" width="10" height="10" rx="2" fill="none" stroke="#3fb950" stroke-width="1.5"/>
<text x="356" y="404" fill="#8b949e" font-size="9">console proxy</text>
<!-- ==================== BOTTOM DETAILS ==================== -->
<line x1="30" y1="430" x2="1170" y2="430" stroke="#21262d" stroke-width="1"/>
<!-- Routing rules at bottom, left-aligned -->
<text x="44" y="456" fill="#30363d" font-size="9" font-weight="600" letter-spacing="1">ROUTING</text>
<circle cx="44" cy="474" r="3" fill="#f47067" opacity="0.6"/>
<text x="54" y="477" fill="#484f58" font-size="9">target_node set &#x2192; route to specific node queue</text>
<circle cx="44" cy="494" r="3" fill="#f0883e" opacity="0.6"/>
<text x="54" y="497" fill="#484f58" font-size="9">ws_id set &#x2192; route to owning node</text>
<circle cx="44" cy="514" r="3" fill="#58a6ff" opacity="0.6"/>
<text x="54" y="517" fill="#484f58" font-size="9">neither &#x2192; shared queue, any node picks up</text></svg>
<circle cx="44" cy="474" r="3" fill="#3fb950" opacity="0.6"/>
<text x="54" y="477" fill="#484f58" font-size="9">control plane: client &#x2192; console &#x2192; server node (hash-ring bucket lookup)</text>
<circle cx="44" cy="494" r="3" fill="#58a6ff" opacity="0.6"/>
<text x="54" y="497" fill="#484f58" font-size="9">data plane: client &#x2192; server node (direct SSE, node_url from create response)</text>
<circle cx="44" cy="514" r="3" fill="#f47067" opacity="0.6"/>
<text x="54" y="517" fill="#484f58" font-size="9">single-node: client &#x2192; server (direct HTTP + SSE, no console needed)</text>
</svg>

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 16 KiB

+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9a1b0361c466327d0011a488847ea3c0365983713537d4a7c27cd7f5538ba33c
size 164829
oid sha256:881a8b9bce67b5af9a52d5e50deaa72351cd99c76f18aad5caeb2b61131ca1af
size 119798
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7d75da92a657525bcbb7a425dc6c8d3cafe074c3ac3bff3cf4b1d44aea607b50
size 330156
oid sha256:a3b5c59403a6febd81667fc8fd2a7d22bc59da6130eba0dea5449c42668d0ede
size 387044
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:637458e0d78df82752746e519cd7300a830c8ce211f21625694ad0c162ca316d
size 481637
oid sha256:474b900448ec04d1117b48a2b55614524721b2f04ac4bda66170bd0a06aae0f2
size 624573
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:dc3b64c9e48153641af62ed43fbc1d89a31d1a8a61e7e71cfc550c805000310d
size 288290
oid sha256:3aa8d972bba40d78152f9f0c762b9f5ec616d8052c45fa52b7dd1c679ed81d61
size 325245
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b842683d238664a3e35d04358fecfc56cefd013f7dca5f13357b0376f881e1b3
size 245043
oid sha256:674712a0563f51837383184652efeb28b7bec13378be636e89d2959bfba39d1e
size 281519
-3
View File
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b22d5980fe5cc4b8466ba0797113dc8fa83fab8df24b5dacceaf97e62e2e25b0
size 187649
-3
View File
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:90e4f74be795b530e711faa87bc6eb2b3bf6abb68d8fac8ebff7aaf30c6fbe53
size 222032
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d33b9b3affcdb07086b5aebca8a3b9c2b009cdfc6f360950a0e72e65fbcb8f17
size 201602
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:adac93a0bb062d7199b819a600a0983ff011a75d16928fb80322cbb41f9284ea
size 158866
oid sha256:04d2069a9b5155ad1e7d842147fd78535ad9106d6856520439c33a9868a47499
size 156694
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:69f201cff948cb0a19810b7c4ad26d346f869ee2dd3141eba4f353332efa2e21
size 373649
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:97e7210cd8f1ad195f4d5e25e778d82df3c08c5c6e0f09722e84a7a453714867
size 411664
oid sha256:040f7d9ec7d676da40b9487e0825caf2c1574cbdd9f16d0998d90e0c2e4f8861
size 360309
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4c3214ef416c1dfe4fa17834c2b6f4071a8093cfdb2b862848ca79938f726a13
size 252599
oid sha256:a872556d111185f4531d1b68ee892b4ce5042d7ccf277e2cad08beb6932c9803
size 191144
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c9823a41e09611c5c0530d9fc12ad4139cfcc3ae238dc665b2888ec94d7d6781
size 195708
oid sha256:e7c3e40c10425d721f833390ae3531c09af501157fd3142531ba4eba86ff719d
size 197112
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:733aa17cbfdab60a601cac6adf439c657dd3535e3d6c33c69c2ef93ba8ec5989
size 251042
oid sha256:b047cdc318c505f0f0895a65e14c5cc7552716053055cca57fa0a77db150e618
size 255458
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f4b2a2010335f986511c8dabaf49ec046ac02e577f9bc9924897e045f860bb13
size 248808
oid sha256:af5ab3126bf685afe68e24bc4b0ed97371d0ebdb77bf4d76c0331ab120580cc0
size 248809
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6049cc0b07480df88d0d93aa977a1e97f64b41588325ff41d98be0e39431fc5c
size 431712
oid sha256:ae4f79fb22600106f8cb0af4ba5586bb26ea5d57e27ef382fdc59b6549fdbd21
size 415473
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f55e177e0838a16d9bc4f07b162b4b6a966cc596c9d0a022d35a3c84f23e7b02
size 221452
oid sha256:cc4c511c34a2e5d286fd128c3509405a5b240ca02a4bafb395d2e94d002a5b8b
size 293203
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:96176a09e65e90dadc32d5e9ed778423842be89204d2cf382225f53a90cfaf01
size 258547
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:98ba80fa1dab4d37299e61be079a6fbc8740fc3ab92196f828a765f74caf4556
size 200720
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7623df33be9baf7647ca1c2450640df57e1cd73e8be1f8168aae16e546ad683c
size 459941
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:79a690c466a5d6f6d4292d78a27b9474e9e9c1373fa80e17dfe37700238c8af8
size 382508
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c89628ed917dfd576c1af75c68fe5fed9beadaaee9dcea7aa7a1643867c4f1b9
size 344323
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:06fe076f0835a891e00afc804fd1805196ebde9fc0d34998c7873e87287f982b
size 346887
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1c21910e3916be789b0377c8a0dcc8f47d66a967861a543d5bdd0c26da185259
size 309584
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ba14003062fec7eb9eaca7a3de945767e40bdd821468e19e7d1edcfa7ce1eb41
size 193581
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:aa12d81dc578f7e65bf4df3152b3de1736289c422f83d0b0cd32107726722357
size 172028
+31 -50
View File
@@ -1,6 +1,6 @@
# Docker Deployment
Docker Compose stack for running the full turnstone platform or the simulator.
Docker Compose stack for running the full turnstone platform.
## Quick Start
@@ -10,9 +10,6 @@ cp .env.example .env
# Full stack (needs an LLM API on the host)
docker compose up
# Simulator only (no LLM needed)
docker compose --profile sim up redis console sim
```
Console dashboard: http://localhost:8090
@@ -23,18 +20,14 @@ Console dashboard: http://localhost:8090
| Service | Port | Profile | Description |
|---------|------|---------|-------------|
| `redis` | 6379 | default | Message broker, pub/sub, node registry |
| `server` | 8080 | default | Web UI + chat workstreams + LLM |
| `bridge` | — | default | Redis-to-HTTP bridge (multi-node routing) |
| `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) |
| `bridge-1``bridge-10` | — | cluster | Matching bridge fleet |
| `sim` | — | sim | Multi-node cluster simulator |
## Profiles
**Default** (no flag) — starts `redis`, `server`, `bridge`, `console`. Requires an OpenAI-compatible LLM API running on the host (default: `http://localhost:8000/v1`).
**Default** (no flag) — starts `server` and `console`. Requires an OpenAI-compatible LLM API running on the host (default: `http://localhost:8000/v1`).
```bash
docker compose up
@@ -46,22 +39,12 @@ docker compose up
docker compose --profile production up
```
**Cluster** — 10-node server/bridge fleet sharing PostgreSQL and Redis. Access all nodes via the console at `:8090`. Requires `POSTGRES_PASSWORD`:
**Cluster** — 10-node server fleet sharing PostgreSQL. Access all nodes via the console at `:8090`. Requires `POSTGRES_PASSWORD`:
```bash
docker compose --profile cluster up
```
**Sim** — adds the simulator. Can run alongside the full stack or standalone with just Redis and the console:
```bash
# Sim + console (no LLM needed)
docker compose --profile sim up redis console sim
# Everything including sim
docker compose --profile sim up
```
## Configuration
All configuration is via environment variables in `.env` (copy from `.env.example`):
@@ -74,13 +57,6 @@ All configuration is via environment variables in `.env` (copy from `.env.exampl
| `OPENAI_API_KEY` | `dummy` | API key (`dummy` for local servers) |
| `TAVILY_API_KEY` | — | Web search API key (only needed for local/vLLM models; Anthropic and OpenAI search models use native search) |
### Redis
| Variable | Default | Description |
|----------|---------|-------------|
| `REDIS_PASSWORD` | — | Redis auth password (empty = no auth) |
| `REDIS_PORT` | `6379` | Host port mapping |
### Server
| Variable | Default | Description |
@@ -93,25 +69,31 @@ All configuration is via environment variables in `.env` (copy from `.env.exampl
| Variable | Default | Description |
|----------|---------|-------------|
| `CONSOLE_PORT` | `8090` | Host port mapping |
| `CONSOLE_POLL_INTERVAL` | `10` | Node polling interval (seconds) |
### Auth
Auth is always enabled. `TURNSTONE_JWT_SECRET` is required.
| Variable | Default | Description |
|----------|---------|-------------|
| `TURNSTONE_AUTH_ENABLED` | — | Set to `1` to require authentication |
| `TURNSTONE_AUTH_TOKEN` | — | Config-file token for server/bridge/console (backward compat, works alongside JWT) |
| `TURNSTONE_JWT_SECRET` | — | Secret key for signing JWTs (required when using user identity / JWT auth) |
| `TURNSTONE_JWT_SECRET` | — | Secret key for signing JWTs (required) |
### Database
| 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:
>
> ```bash
@@ -126,36 +108,33 @@ 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 through Redis MQ to the bridge and server. See [Channel Integrations](channels.md) for full setup instructions including Discord application creation and user account linking.
### Simulator
| Variable | Default | Description |
|----------|---------|-------------|
| `SIM_NODES` | `100` | Number of simulated nodes |
| `SIM_SCENARIO` | `steady` | Scenario: `steady`, `burst`, `node_failure`, `directed`, `lifecycle` |
| `SIM_DURATION` | `60` | Duration in seconds |
| `SIM_MPS` | `5.0` | Messages per second (steady scenario) |
| `SIM_LOG_LEVEL` | `INFO` | Log verbosity |
| `SIM_SEED` | — | Random seed for reproducibility |
| `SIM_METRICS_FILE` | — | Write JSON report to file |
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
For multi-node testing, use the `cluster` profile which provides 10 dedicated server+bridge pairs with unique node IDs (`node-1` through `node-10`), resource limits, and shared PostgreSQL:
For multi-node testing, use the `cluster` profile which provides 10 server instances with unique node IDs (`node-1` through `node-10`), resource limits, and shared PostgreSQL:
```bash
POSTGRES_PASSWORD=secret docker compose --profile cluster up
```
The default `server` and `bridge` also run alongside the cluster nodes (11 total). All nodes are accessible via the console dashboard at `:8090`.
The default `server` also runs alongside the cluster nodes (11 total). All nodes are accessible via the console dashboard at `:8090`.
For production clusters beyond ~50 nodes, add PgBouncer between turnstone services and PostgreSQL. See [PgBouncer Connection Pooling](pgbouncer.md) for Docker Compose and Helm configuration.
## Volumes
| Volume | Mount | Purpose |
|--------|-------|---------|
| `redis-data` | `/data` | Redis persistence |
| `turnstone-data` | `/data` | SQLite database (`.turnstone.db`) |
## Building
@@ -170,7 +149,9 @@ docker compose build
docker compose build --no-cache
```
All entry points are installed in a single image: `turnstone-server`, `turnstone-bridge`, `turnstone-console`, `turnstone-channel`, `turnstone-admin`, `turnstone-sim`, `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

Some files were not shown because too many files have changed in this diff Show More