Compare commits

...

62 Commits

Author SHA1 Message Date
Patrick Buckley 019d13d930 chore: bump version to 1.6.2 2026-06-11 20:53:13 -07:00
Patrick Buckley 8fbcbff566 test: zero out the suite's warning noise
121 warnings -> 0. Two upstream deprecations get narrowly-scoped
filterwarnings entries (the mcp streamablehttp_client rename — adoption
deliberately rides the v2 migration since the new entry point's call
shape changes again there; the starlette httpx TestClient notice). The
one real RuntimeWarning is fixed at the source: tests that mock
asyncio.run_coroutine_threadsafe handed real coroutines to a stub that
never awaited them, GC-firing 'coroutine was never awaited' inside
whatever unrelated test ran later (the same cross-test bleed mechanism
as the CI closed-stream spew — per-test filterwarnings markers cannot
catch it, which is why two such markers existed and still leaked). A
shared _dispatch_stub now closes real coroutines before returning the
canned future; the obsolete markers are removed.
2026-06-11 20:44:10 -07:00
Patrick Buckley a27738867f chore: cap mcp <2 ahead of the v2 breaking rewrite
mcp 2.0.0a1 shipped 2026-06-11 (stable targeted ~2026-07-27). v2 removes
streamablehttp_client, changes the transport tuple arity, and renames
mcp.types fields to snake_case — all of which our client imports. The
maintainers' release note asks downstream packages to add an upper
bound now (their worked example is this exact constraint). Floor stays
at 1.27: nothing newer adds anything our surface needs, and the #2147
shutdown busy-loop we wrap remains unfixed at every released version.
Resolution is unchanged (1.27.2); lockfile re-pinned metadata only.
2026-06-11 20:44:10 -07:00
renovate[bot] 1d0be9773f chore(deps): update docker images to v0.11.21 2026-06-11 20:44:10 -07:00
Patrick Buckley ad56e1ec96 fix(providers): require base_url for anthropic-compatible
Copilot review on #661: empty base_url let the SDK fall back to
https://api.anthropic.com, sending compat-shaped requests to the
commercial API. The lane is local-only by definition, and the /v1-strip
edge case already established fail-loudly-over-silent-prod-retarget;
apply the same principle to the empty case. create_client raises an
actionable ValueError; the admin Detect path surfaces it as a clean
error string via probe_model_endpoint's existing handler.
2026-06-11 20:44:10 -07:00
Patrick Buckley 8f0115ee2e feat(providers): anthropic-compatible lane for local /v1/messages servers
Add provider id "anthropic-compatible": the existing AnthropicProvider
pointed at Anthropic-compatible local servers (vLLM /v1/messages),
mirroring the openai/openai-compatible split. Registry-only — configured
via the admin Models tab or [models.*] toml, not exposed on the bare
--provider flag, so the CLI/server prod-URL defaults are unreachable for
the lane and real-Anthropic behavior is untouched.

Lane behavior (live-verified against vLLM 0.22.1rc1 + DeepSeek-V4-Flash):
- Capability defaults replace the Claude static table: token_param
  max_tokens, thinking_mode none, web_search/tool_search/vision off,
  reasoning replay on. vLLM rejects Anthropic server-side tool types
  (tools require input_schema) and ignores the thinking request param,
  so neither is sent; thinking blocks still stream back and round-trip
  through the native lane verbatim.
- Reasoning toggles via server_compat extra_body chat_template_kwargs
  (first-class vLLM request field; request-level keys beat server
  defaults). _build_thinking_and_kwargs forwards non-internal
  extra_params as SDK extra_body; thinking_budget_tokens stays internal.
- No temperature force: thinking_mode none skips the Claude-only
  temperature=1.0 requirement.

Admin UI: provider option + URL placeholder (base_url without /v1 — the
SDK appends /v1/messages); the server-compat section shows only the
extra-body field for the lane. thinking_mode round-trips through the
form dropdown for every provider except anthropic-compatible, where it
stays in the raw capabilities JSON — the edit-load lift and save restore
use the same predicate so stored overrides are never silently dropped.

Docs: architecture.md gains the lane subsection incl. verified quirks
(thinking param dropped by vLLM; stop_sequences cut inside thinking and
report end_turn; usage has no cache fields; images need a multimodal
model; mid-conversation system turns are per-model opt-in).

Negative-tested: removing the _INTERNAL_EXTRA_PARAMS exclusion fails
test_internal_keys_not_leaked; the live test drives a streamed turn with
the chat_template_kwargs toggle and asserts no reasoning deltas.
2026-06-11 20:44:10 -07:00
Patrick Buckley 7aba631201 fix(mcp): close the shutdown drain race + close the owned loop
Review feedback: (1) gating the drain on a main-thread truthiness check
of _background_tasks could skip cancellation when a spawn queued via
call_soon_threadsafe had not reached the set yet — submit whenever the
loop is RUNNING and snapshot on the loop, where FIFO callback order
guarantees earlier-queued spawns have landed; (2) shutdown stopped the
loop thread but never closed the loop or cleared _loop/_thread, leaking
selector resources for embedders that cycle managers — close + clear
when we own the thread and it actually stopped (loud warning when it
does not); unowned loops (tests wiring _loop directly) stay untouched;
(3) the bare await-in-suppress drain loops become
asyncio.gather(return_exceptions=True) in both the shutdown drain and
the test fixture.
2026-06-11 20:44:10 -07:00
Patrick Buckley f3f5e84f2d fix(mcp): track fire-and-forget background tasks; harden loop teardown
The post-reconnect catalog refresh was scheduled as a bare
asyncio.create_task: no strong reference (the task could be GC'd
mid-flight, so the refresh might silently never run) and no exception
retrieval (failures surfaced as "Task exception was never retrieved"
at GC time — in CI, onto an already-closed pytest capture stream, the
"I/O operation on closed file" spew; a suspected contributor to the
flaky 60-minute CI hangs via cross-test loop/task state bleed).

- _spawn_background(coro, label): tracked-task set + done-callback
  that retrieves and logs failures at warning; discard runs LAST so
  set-emptiness means "done AND reported"
- shutdown() drains tracked tasks FIRST, so stack teardown can't race
  an in-flight refresh; same run_coroutine_threadsafe idiom and
  timeouts as the existing close steps
- running_loop_mgr fixture: cancel-pending -> drain -> stop ->
  join(5) with a loud assert -> loop.close() (was stop + silent
  join(2), never closed)
- the false-property test ("swallows refresh failure" — nothing
  swallowed it) now waits for completion and asserts the logged
  warning via the patched module logger (structlog; caplog cannot
  observe it), polling inside the patch context
2026-06-11 20:44:10 -07:00
Patrick Buckley ff1e3e5c1c fix(storage): enforce orphan-ness inside the purge DELETE + chunk IN-lists
Review feedback on the purge's race window: the pre-SELECT re-verify
left a statement-to-statement gap where a concurrent registration could
still lose rows — and the pre-counted refcount release could underflow
when it didn't. Orphan-ness now rides the DELETE itself (correlated
NOT EXISTS) with refcounts released from its RETURNING, so refs are
released for exactly the rows that were deleted. Input is de-duplicated,
IN-lists chunk at the storage layer's 500 convention, and the scan's
per-workstream ref-count loop is now one anti-join pass.
2026-06-11 14:13:13 -07:00
Patrick Buckley f0d7305b28 feat(admin): orphan-conversations maintenance verb — scan + purge
Conversation rows whose workstreams row is gone (historical unregistered
writers; the delete-during-inflight race re-creating rows after
delete_workstream) are invisible cruft that also pins attachment
refcounts. Add a turnstone-admin verb: default = read-only scan report
(ws_id, rows, attachment refs, first/last); --delete [--yes] purges.

- shared find/purge logic in storage/_utils; protocol + both backends
  in lockstep (thin wrappers)
- purge re-verifies orphan-ness in-transaction: a ws_id re-registered
  between scan and purge is skipped, never deleted
- releases the deleted rows' attachment refcounts through the
  delete_workstream GC path and sweeps workstream_config/overrides
- summary reports actual purge results, including the skipped clause
2026-06-11 14:13:13 -07:00
Patrick Buckley 84a545cb21 fix(ui): re-home MCP consent badge on the Manage Connections row (#657)
* fix(ui): re-home MCP consent badge on the Manage Connections row

The L-shell renovation retired the standalone settings gear (#settings-btn).
The MCP pending-consent badge anchored to that gear via _refreshConsentBadge,
which null-guarded silently — so since the renovation pending consent requests
had no indicator (the badge was invisible).

Re-home the badge on the rail's Manage row where the MCP/connections surface
lives in both deployments:

- rail.js gains a generic setRowBadge(tabKey, count, label?) hook + a `badge`
  builder: a small ⚠-glyph + count chip (never colour alone) using the DS warn
  tokens. mountManage registers row + owning-group-head refs and re-applies live
  counts across a (re)mount. When the owning group is collapsed, the count also
  mirrors onto the group head so a hidden row never hides the signal. rail.js
  stays agnostic — it owns the mechanism, the caller owns the meaning.
- shell.js (the ESM bridge) re-exports setRowBadge on window.TS_SHELL so the
  classic ui/static/app.js subsystem can drive it without importing the module.
- The standalone consent subsystem keeps its shell-level ownership: _refresh-
  ConsentBadge now drives setRowBadge on the Connections tab, fed by both the
  loadPendingConsents hydrate/poll load and live onConsentDetected notifications.
- The shared interactive pane host bridges onConsentDetected to the new
  window.TS_APP.onConsentDetected seam (undefined on the console, so the console
  pane stays a no-op there); panes only notify.
- The dead colour-only gear badge CSS (.settings-consent-badge, red dot) is
  removed; the new chip lives in shell.css as token-only .rail-badge so it
  flips themes by construction.

Console MCP tab (Extensions > mcp) and standalone Connections tab
(Extensions > connections) both badge correctly. Pins extended in
test_shell_js.py + test_app_js.py.

* fix(ui): drop the unused head ref from the rail badge row map

Review feedback: _rowEls stored each row's group-head element but every
head consumer resolves it through _groupEls; keeping the duplicate DOM
ref made the remount state shape harder to reason about.
2026-06-11 14:13:13 -07:00
Patrick Buckley 8e11929ba0 chore: bump version to 1.6.1 2026-06-11 14:09:31 -07:00
Patrick Buckley d9e9a41b17 test(console): make dedupe-pin slice bounds reformat-tolerant
Review feedback: the next-case end markers were exact-indentation
string finds that raised a bare ValueError when unmatched. Use
whitespace-tolerant regexes with actionable assertion messages, and
bound the history-replay window structurally (next role branch, with
a generous fallback) instead of a fixed 600 chars.
2026-06-11 14:05:19 -07:00
Patrick Buckley 848b2cc1fb fix(ui): single-path Enter activation + hls.js teardown on player error
Review feedback: (1) the Enter keydown re-dispatched through btn.click(),
relying on the disabled-guard to suppress the browser's own
Enter-to-click — preventDefault + direct activation makes the keyboard
path provably single-fire; (2) the branch-scoped Hls instance was
unreachable from the media error handler, leaking its listeners and
loader timers when the player node was replaced with the retry UI —
hoist the ref and destroy it before replacement.
2026-06-11 14:05:19 -07:00
Patrick Buckley 1946002618 fix(ui): lift media player activation into the shared interactive pane
The interactive Pane renders media embeds (buildMediaEmbed / buildPlayButton),
but the Play activation — _loadHls / _isHlsUrl / _activatePlayer and the
click/keydown delegate — stayed behind in the standalone ui/static/app.js as
DOCUMENT-level listeners. The console L-shell mounts the same interactive.js
module but never loads ui/static/app.js, so the Play button was dead in
console-hosted interactive panes.

Lift the activation into shared_static/interactive.js (alongside the existing
buildMediaEmbed/buildPlayButton — media embeds are interactive-pane-only; the
coordinator pane renders none) and wire it as a pane-owned, root-scoped
this.el click/keydown listener, mirroring the approval-keydown pattern the
fork collapse established. The standalone copy is deleted so no duplicate
implementation remains; both deployments now activate through the one shared
handler.

The hls.js vendor is fetched lazily by absolute /shared/ URL (the same
mechanism renderer.js uses for mermaid), and /shared is mounted at the root in
both turnstone/server.py and turnstone/console/server.py, so the vendor —
which ships in shared_static/hls-1.6.16/ — resolves in both deployments with
no HTML change.

Pins: assert the lift + pane-ownership in test_interactive_pane_js.py and the
standalone-stays-clean guard in test_app_js.py.
2026-06-11 14:05:19 -07:00
Patrick Buckley 4bce6abc7c test(console): pin system-turn dedupe wiring on both read paths
The live-SSE/history system-turn dedupe (renderedSystemEventIds /
_renderedSystemEventIds) was already in place on both panes and merged
to main (21af6c4 aligned the persisted row event_id with its SSE event;
09e41d1 added the belt-and-braces Set on the coordinator). The existing
pin tests only assert the Set's .has()/.add()/.clear() symbols appear
somewhere in the file, so a refactor that keeps the Set but short-circuits
the live-handler consultation (guard -> false) re-opens the double-render
while the pins stay green.

Scope the new assertions to their blocks: the live system_turn case must
CONSULT and RECORD against the Set, and the history render path
(replayHistory / refetchHistory's system-role branch) must record each
replayed row's event_id. Bounded at the next switch case rather than the
first break; the dedup-skip path itself breaks before the .add(), so a
break-bounded slice would drop the record half.

Verified the new slice checks fail on a dedupe-neutered factory (a
headless-Chrome harness driving the real createCoordinatorPane confirms
that neutering produces two rendered nodes for one event id; intact code
renders one, and the no-event-id legacy path still renders both).
2026-06-11 14:05:19 -07:00
Patrick Buckley c19432f12a fix(memory): touch access metadata on composition and tool reads
The touch_structured_memories facade and both storage backends were
implemented but had zero call sites, so access_count never moved and
last_accessed never advanced past write time on any deployment.

Wire two touch points:
- proactive composition touches the injected top-k (post-rerank) set,
  deduped per turn since _init_system_messages recomposes many times
  within a single turn;
- the memory tool's search and get reads touch their returned rows,
  counted per call. save/delete/list do not touch.

Touches are best-effort through the facade, which already swallows
storage errors, so a failed touch never breaks composition or a tool
call.
2026-06-11 14:05:18 -07:00
Patrick Buckley 23007e3ac5 chore: bump version to 1.6.0 2026-06-10 22:21:03 -07:00
Patrick Buckley f44886a55f docs: 1.6.0 changelog + release-track policy (#653)
* docs: 1.6.0 changelog — roll up the 1.5→1.6 line for stable

Replaces [Unreleased] with the 1.6.0 section: 320 main-only commits
since the stable/1.5 divergence grouped into theme bullets (license,
trajectory/migration-060, web search, rerank/memory, approvals/judge,
L-shell, shelf, SSE, providers, cluster ops, security). Breaking
changes aggregated up top; migration-060 backup callout reshaped from
discussion #631 for the stable audience.

* docs: add the stable/1.6 track to the changelog preamble

* docs: retire the stable/1.4 track — current + one prior policy

Changelog preamble down to three tracks with the policy stated;
1.4 retirement noted in the 1.6.0 Removed section (final release
v1.4.0; tags/artifacts remain, BUSL-1.1 as shipped). releasing.md
track table, policy bullet, and examples brought up to the 1.6.0
promote cycle — the doc was still describing the 1.4-stable era.
2026-06-10 22:19:58 -07:00
Patrick Buckley 3803feb008 fix(console): uniform not_found wait-entry keys + ws_ids param precision
PR #652 review follow-ups:
- not_found snapshot entries now carry the full key set (updated/name
  empty) so results[ws_id] is shape-uniform across states; pinned by a
  key-set assertion in the sentinel test
- ws_ids param text now distinguishes malformed (fails before any
  waiting) from well-formed-but-unobservable (first-tick abort) at
  unchanged length — per-param descriptions stay lean by policy
2026-06-10 22:03:08 -07:00
Patrick Buckley d0e9aa3dbe fix(console): coordinator ws-ref validation, did-you-mean recovery, wait fail-fast
Field incident: the coordinator LLM hand-copied a child ws_id and
collapsed its aaa run to a, producing a 30-char id. inspect said "not
found", wait called it "denied", neither offered recovery, and the model
concluded the child was dead and dropped the lane — silent report
degradation while the child kept working.

- validate model-supplied ws_id args at the tool boundary
  (send/close/cancel/delete/inspect/wait): full 32-hex ids pass through
  at unchanged storage cost; a child's exact legacy id still resolves;
  anything else fails fast with a did-you-mean (capped Levenshtein <=3
  over the coord's own children) plus a child roster. Near-misses never
  auto-resolve; display names are not addresses (mutable, non-unique) —
  a name ref errors with a pointer at the right id
- wait_for_workstream: rename per-entry state "denied" -> "not_found"
  with an honest sentinel; malformed refs error before any waiting
  (invalid_ws_ids); a well-formed id that is foreign, missing, or
  hard-deleted mid-wait aborts the wait on the tick that observes it
  instead of burning the timeout (mode=all was unsatisfiable) or riding
  along to complete=True (silent lane loss); mode=all completes only
  when every id is real-terminal; entries carry the child display name
- one not-found payload across all verbs: foreign and nonexistent stay
  byte-identical (no existence oracle), hints reference only the coord's
  own children, echoed refs clipped in error strings; invalid_ws_ids and
  not_found share one per-ref shape with the roster hoisted top-level
- inspect ownership now requires user_id parity via _row_in_own_subtree,
  matching the wait/mutating gates (#506) — closes the forged-parent
  cross-tenant read
- session exec serializes the structured recovery payload (results +
  did_you_mean + children) on unresolvable-id wait errors instead of
  collapsing to the bare error string
- tool JSON descriptions + coordinator docs updated to the new contract;
  incident regression test pins the captured aaa-collapse ids
2026-06-10 22:03:08 -07:00
Patrick Buckley 81a3eaecce chore: relicense BUSL-1.1 → Apache 2.0 for 1.6.0 (#651)
* chore: relicense BUSL-1.1 -> Apache 2.0 for 1.6.0

Flips every license artifact in the tree; 1.5.x and earlier remain
BUSL-1.1 per their release-time LICENSE files. Contributor consent
record: #548 (rationale: #546).

- LICENSE: canonical Apache 2.0 text
- NOTICE: new; copyright line + pointer to THIRD-PARTY-NOTICES
- pyproject.toml: SPDX expression + explicit license-files trio
- Dockerfile: COPY the license trio (hatchling needs them at build)
- THIRD-PARTY-NOTICES: BUSL line reworded; bundled-version drift
  fixed (KaTeX 0.17.0, Mermaid 11.15.0, hls.js 1.6.16)
- README badge + License section, CONTRIBUTING inbound-license line,
  TS SDK package(+lock), example pyproject
- docs/pgbouncer.md: drop stray ':' introduced in #353

* docs: add CONTRIBUTORS.md

* chore: drop LICENSE leading blank line

The apache.org LICENSE-2.0.txt begins with a newline; the SPDX
canonical text and GitHub license templates do not. Use the
conventional form — detection is whitespace-normalized either way.
2026-06-10 21:03:16 -07:00
Patrick Buckley 9b75a12848 fix(server): re-author --skip-permissions CLI flag wiring
Independent re-implementation of the --skip-permissions argparse flag,
OR-ed with the tools.skip_permissions config-store setting at both
consumption sites. Written from the flag's pre-existing spec (the
--help epilog and compose.yaml, which referenced it before #450
existed).

Replaces reverted #450 so that 1.6.0 ships no non-consented
contributions under Apache 2.0. Provenance record in #548.
2026-06-10 20:37:44 -07:00
Patrick Buckley 695a335744 Revert "fix(server): accept --skip-permissions CLI flag (#450)"
This reverts commit 2cdf87b115.
2026-06-10 20:37:44 -07:00
Patrick Buckley 9e0c86e78a fix(console): persona-btn radius onto the r-sm token
Copilot round: the 3px literal (carried from the hatch .seg segments)
disagreed with the shared :focus-visible rule, which restates
border-radius as var(--r-sm) — so the corner radius popped on keyboard
focus. One token, no jump.
2026-06-10 20:11:45 -07:00
Patrick Buckley c15dcee1f5 ui(console): launcher persona toggle wears its kind — amber coord / cyan int
The dashboard launcher's Coordinator|Interactive radiogroup styled its
active option as a neutral panel highlight — two faint text links that
said nothing about WHAT was being chosen. The active option now takes
the kind vocabulary the rest of the shell already speaks (.ptag.coord
amber / .ptag.int cyan): 15% kind tint, kind-colored label, and a kind
LED dot, in a recessed .seg-style track. Colour is never alone — the
LED + label weight carry the state, and the JS contract (classList
toggle on .active, aria-checked, roving tabindex) is untouched.
2026-06-10 20:11:45 -07:00
Patrick Buckley b3c3acc5d1 fix(scripts): livepass dialog-tier riders + loud open-failure + dock-displacement probe
Designer-review round on the scroll fix found the harness's dialog-tier
gate silently green: confirm-dialog (and install/coord-delete) markup
lives OUTSIDE #admin-layout, so the fragment extraction never embedded
it — ?open=confirm threw at showConfirmModal and screenshot a normal,
dialog-less page. build() now injects every hatch dialog the fragment
does not already contain, and a driven ?open= that ends with no open
dialog stamps OPEN-FAILED-<state> into the title instead of passing.

Also upstreams the review's probe states: &focuslast=1 focuses the last
shelf-body control (the displaced-dock regression class — only .sh-body
may scroll; head/foot must stay pinned) and &scrolled=bottom shows the
24px scroll tail.
2026-06-10 18:51:31 -07:00
Patrick Buckley 6bb47cad2d fix(console): manage-pane scroll regressions — interior scroller, clip the hatch-host, anchor hidden inputs
The L-shell height-pins the admin chain and .hatch-host clipped it, so no
box below the pane could scroll: tabs taller than the pane were cut dead,
and the overflow:hidden host doubled as a hidden scroll container that
focus-into-view silently scrolled — visually-hidden toggle/cap/radio
inputs escape the .sh-body scroller (abspos under an unpositioned label),
overhang the shelf, and a Tab keypress shoved the docked hatch off its
head with no scrollbar to recover by.

- .admin-content becomes the manage pane's interior scroller (the #main
  precedent); switchAdminTab resets it on real tab changes only
- .hatch-host: overflow hidden -> clip — paint clipping without a scroll
  container, so focus can never displace the dock
- position:relative anchors on the three hidden-input labels
  (toggle-switch, .sh-body .cap, segmented-option); .settings-toggle
  already carried one
- livepass: the console harness wraps the fragment in the REAL L-shell
  chain (its bespoke height pin is exactly how this bug class stayed
  invisible to the screenshot gates) and gains a ?tall=1/&scrolled=1
  scroll state
2026-06-10 18:51:31 -07:00
Patrick Buckley 160062235a chore: bump version to 1.6.0rc2 2026-06-10 13:35:47 -07:00
Patrick Buckley b991dc2e83 fix: CI lint pin + copilot-thread hardening
The wiring-lint test used percent-formatted regex patterns — UP031 under
the ruff 0.15.6 the CI pre-commit pins (the older venv binary let it
through; checked repo-wide against the exact pin now). f-strings with
doubled quantifier braces, plus one over-long fixture line in the
livepass generator split.

Copilot threads, both validated rather than blindly applied:
- closeShelf's scrim-ownership scan now skips detached entries. The
  thread's throw scenario doesn't occur on the real removal path (a pane
  close detaches an ANCESTOR, so _hostOf still resolves inside the
  detached subtree) — but a detached shelf is genuinely not a scrim
  owner, so the guard is correct beyond being defensive.
- toast.js drops the popover attribute via removeAttribute instead of
  the null assignment. The claim that null leaves popover="null" is
  refuted — the IDL is nullable and null removes the attribute (verified
  empirically in headless Chrome) — but removeAttribute reads correct
  without requiring that spec knowledge.
2026-06-10 13:31:53 -07:00
Patrick Buckley 9102f858a4 chore(scripts): commit the livepass harness generator
The livepass harness — the headless-render rig that verified every
converted modal surface and click-drives submits (the dead-Save bug
class) — lived as ad hoc files in /tmp and got wiped once already.
The durable piece is the GENERATOR: the markup is extracted fresh from
the index files at build time (a committed snapshot would drift) and
the stylesheets/scripts are symlinked so edits are live on refresh.

scripts/livepass.py builds both harnesses into /tmp/livepass/ (ui:
all six dialog-tier surfaces incl. the real cards.js batch controller
drive; console: the admin-pane fragment hosting the shelves, with
schedule/model/policy/confirm/token fixtures and the model-save click
drive that flips document.title to PUT-OK-<n>). --serve included;
the chrome screenshot incantation and the ?open= registry are in the
module docstring. Governance fixtures (roles/HR/OGP/memory/skill) are
documented seams for when those surfaces need driving.
2026-06-10 13:31:53 -07:00
Patrick Buckley 8b5af71603 fix(console): wire the model shelf's Save button — and lint the whole class
The models conversion dropped the legacy onclick= from the submit button
and wired detect/recalibrate/capgrid/thinking in the boot IIFE — but never
the submit itself. submitCreateModel existed with nothing calling it: Save
clicked dead with no error, exactly what a live test surfaced. None of the
gates could see it — the markup lint checks anatomy not wiring, the review
finders verified the submit function's internals, and the livepass renders
never clicked Save.

Audited every id-bearing button inside every dialog.hatch across both apps
for click wiring: model-create-submit was the only true positive (the
batch confirm buttons wire through cards.js's $() prefix helper and
new-ws-submit wires 106 lines from its getElementById — audit false
alarms). The new test_every_hatch_button_is_wired pins the class: direct
getElementById wiring or wiring through the assigned variable, with the
two prefix-built cards.js ids allowlisted; verified it fails against the
pre-fix tree. Livepass now drives the actual click: Save → busy → one PUT
→ shelf closes + toast.
2026-06-10 13:31:53 -07:00
Patrick Buckley f40404bfb9 fix: review-gate round — croniter calendar guard, results teardown, hidden war
Sixteen-finder review (4 dimensions x 4 subsystem slices) + adversarial
verify: 16/16 findings confirmed, all fixed.

Majors:
- The schedule preview (and create/update via _compute_next_run) 500'd on
  syntactically-valid-but-impossible cron dates: croniter.is_valid passes
  '0 0 30 2 *' but get_next raises CroniterBadDateError. One _next_cron_runs
  helper now owns construction + the guard for both paths; the preview
  answers its 200/valid:false contract, and next[] is one shape (the cron
  branch now carries the UTC offset the 'at' branch always had).
- The batch-delete results view tore down (exit delete mode, refresh the
  stale list) only via the footer Close — header ✕ / Escape / backdrop left
  deleted rows on screen and the mode stuck. The teardown moved onto the
  dialog's onClose (gated by a resultsShown flag so a pre-delete cancel
  keeps the selection), and Close just closes.
- The model shelf's Server-compatibility section was permanently invisible:
  the one hidden-attr element still toggled via style.display, which cannot
  beat .hatch [hidden] !important — openai-compatible operators could never
  reach server type / API surface / extra-body. Now .hidden like its
  siblings.

Minors: shelves prune detached entries when a pane closes mid-edit (state
map + Escape-listener leak); the capabilities autofill gains the
_schPreviewSeq stale-response guard; the rename dialog focuses its input
before select() (select() does not move focus per spec — Enter landed on
the ✕); .mcp-install-source-label becomes the fourth protected label
component; nine write-only shelf-handle vars dropped; one alert region
gets one name; orphaned .modal-col-heading CSS, the stale toast z-index
rationale, a dangling divider comment, and a comment chasing the renamed
_submitRoleShelf all cleaned. Regression tests pin the Feb-30 preview,
the create-path guard, and the uniform next[] shape.
2026-06-10 13:31:53 -07:00
Patrick Buckley 472f12e14a fix(ui): phase-3 design-review round — wrong-target rename, error hygiene, focus
Dual review of the dialog-tier work (primed + cold), adjudicated:

- Menu-launched dialogs lost focus return: openPopupMenu's close() removed
  the focused item before the action ran, so dialogs captured <body> as
  their opener and the close-restore no-op'd. The menu now hands focus to
  its return target before invoking the action — repairs every
  menu→dialog flow in both apps.
- Rename targeted the wrong workstream: submitEditTitle re-read the
  ACTIVE pane's id, so renaming a background tab via its context menu
  renamed whichever pane was focused. The dialog now pins its target at
  open (pre-existing bug, carried from the legacy overlay).
- Batch-failure rows printed raw HTML error pages verbatim (proxy 502s,
  gateway timeouts): bodies are stripped of style/script content and
  markup before display, with an HTTP-status fallback.
- The two single confirms behaved differently in flight — delete-ws
  closed optimistically while revoke held under the busy lock. Unified on
  hold-open-with-busy: failures keep the user's context for retry.
- Batch results: failures announce through the live sh-alert (previously
  dead markup), a clean run flips the chrome to the success kind (red
  head over '3 deleted, 0 failed' disagreed with the de-dangered foot),
  and focus lands on Close after the state swap.
- Ghost-button boundaries measured ~1.2:1 on the foot strip (WCAG
  1.4.11): ink-mix borders routed through a variable so kind variants
  keep their own border colors; reduced-motion busy gains a static ' …'
  cue; dark label-hint opacity brought above the compound 4.5:1 line.
- Revoke voice unified ('Revoke connection', no '?'); batch alertdialogs
  gain aria-describedby; dead _settingsTrap machinery removed.
2026-06-10 13:31:53 -07:00
Patrick Buckley 1dda974b3e test(ui): align the app.js pins with the dialog-tier revoke confirm
The revoke confirm is no longer a role=dialog/aria-modal overlay with
page-local CSS — it's a hatch dialog-tier alertdialog whose chrome lives
in /shared/hatch.css.  The markup pin follows the new shape and the
stylesheet pin list drops the retired #revoke-mcp-overlay rule.
2026-06-10 13:31:53 -07:00
Patrick Buckley e2693764bf feat(ui): batch-delete confirms onto the dialog tier — both hosts, one builder
The shared cards.js multi-select controller serves the ui Saved
Workstreams AND the console Saved Coordinators, so the builder and its
two host markups convert as one unit: #ws-delete-dialog and
#coord-delete-dialog are md danger dialogs whose list renders inside the
sh-body (the only scroll region — the 200px inner list cap dies).

Foot grammar: [N selected meta] [Cancel data-close, autofocus] [red
filled "Delete N workstreams"] — the count moves out of the body prose
into the meta and the action label.  The fan-out brackets with setBusy
(LED pulse + action lock replace the disabled/'Deleting...' swap); the
results view swaps the action to a neutral Close and hides Cancel (a
Cancel beside a Close is the redundant dismissal pair the foot grammar
forbids).  The dormant error region becomes the sh-alert.

The controller's hand-rolled focus trap, prevFocus bookkeeping and
overlay display toggles die — hatch.js owns trap/Escape/backdrop/focus
restore; the post-results Close still hands focus to the section toggle
the bar collapse just rebuilt.  The ws-delete-modal-* CSS family leaves
cards.css (only the row treatment survives); the dead window wrappers
(cancelWsDelete/confirmWsDelete + coord twins) and the ui keydown
handler's last legacy-overlay branch go with it.
2026-06-10 13:31:53 -07:00
Patrick Buckley c592486046 feat(ui): standalone app onto the hatch dialog tier — new-ws, rename, confirms
The ui app has no admin pane host and new-ws is a launcher invokable from
anywhere, so every surface lands on the document-modal DIALOG tier (no
shelves).  hatch.css gains the one md dialog width (560) the v1 design
specified; the ui index links hatch.css/hatch.js alongside the other
shared assets.

- New workstream: md create dialog, WS-NEW plate; the fork path keeps its
  title/semantics (WS-FORK plate, skill + attach rows hidden via the
  hidden attribute).  Submit brackets with setBusy; errors land in the
  sh-alert with the scroll-into-view rule.
- Rename: a styled prompt() — single field, Enter submits, no plate.
- Delete-workstream + revoke-MCP mirror the console confirm exactly:
  danger chrome, prose body, Cancel autofocus, red filled action.

The four hand-rolled focus traps, the Escape/overlay-click dispatch and
the body-overflow lock die (native dialog + hatch.js own all of it); the
global-shortcut handler defers on dialog:modal instead of the overlay-ID
list.  The forked legacy modal CSS leaves style.css (~270 lines).  The
markup-shape lint now scans both index files, the asset assertion and the
parse-time-bridge guard extend to the ui app.
2026-06-10 13:31:53 -07:00
Patrick Buckley c1a0417f10 fix(console): design-review consistency round — plates, verbs, hints, focus
Cosmetic/consistency findings from the dual shelf review, adjudicated;
accepted items:

- One designation-plate grammar: 2-4 char domain code + closed suffix
  vocabulary. SKILL-GH → SKL-IMPORT, PP-* → PPO-*, ROLE-MAP → USR-ROLES,
  ROLE-* → ROL-*; HR-/OGP- stay (established judge domain terms).
- Create titles say "New <thing>" (Add model / Add MCP server renamed;
  "New prompt" was creating a prompt POLICY); "Edit model" gains the
  "— {alias}" suffix every other edit surface carries.
- Shelf primaries uniform single-word Create/Save — the skill shelf's
  "Create skill" / "Save config" / "Save changes" collapse.
- label-hint dialect normalized to bare lowercase (wrapping parens
  dropped); the em-dash unit form "— UTC" is a different species, kept.
- Placeholder-only format instructions promoted to label-hints (a11y
  3.3.2): HR confidence "0.0–1.0", intent "supports {arg_snippet}". The
  install dialog's dynamic required asterisk pairs with required on the
  control + aria-hidden on the glyph.
- The mcp and schedule adjacent toggle pairs wrap in toggle-stack like
  the model shelf; the sch-enabled-row hidden toggle rides inside.
- memory-detail foot grammar: Delete demoted from err-filled to a quiet
  destructive text action (.sh-btn--quiet-danger, doubled class so it
  beats the later-loaded hatch.css quiet color), Close stays rightmost.
- First-focus: user-roles lands on its first role toggle once rendered,
  channel on the type select, builtin-role edit on the first enabled
  control, mcp-install autofocuses its primary.
- The watch-cancel confirm's action reads "Stop watch" — no more
  Cancel-beside-Cancel.
- A locked skill opens as data-kind=inspect (cyan read-out chrome) and
  flips to edit on the in-place unlock re-render.
- mcp-detail healthy node dot pairs with a dim "connected" text token,
  mirroring the error-text sibling (state was color-alone).
- etm-scan section converts to the hidden attribute (last
  style.display straggler on the skill shelf).
2026-06-10 13:31:53 -07:00
Patrick Buckley 360ed48f62 fix(ui): design-review round — busy lock holes, broken auth cards, contrast
Dual design review of the shelf stack (one primed on intent, one cold)
adjudicated; accepted findings:

- The busy lock held the door for the mouse only: Enter on the focused
  primary re-fired submits, scrim clicks closed a shelf mid-flight, and
  the dialog tier's native Escape (cancel event) dismissed a busy confirm.
  One capture-phase guard + a scrim busy check + cancel interception close
  all three for every surface; setBusy now announces aria-busy. Contract
  assertions added to the busy test (it previously claimed scrim coverage
  it didn't assert).
- The MCP auth radio cards were destroyed by the .sh-body label cadence —
  the exact specificity war the toggle-switch/cap exceptions guard
  against, missed for .segmented-option. Restated at 0,2,1.
- Light-theme off-state toggle tracks measured ~1.2:1 (WCAG 1.4.11 wants
  3:1): real ink fill on light, recessed look kept on dark.
- Failed submits on tall shelves rendered out of view — _showModalError
  scrolls the alert into view. user-roles gains the missing busy bracket
  and in-shelf errors instead of toast-only.
- Toasts render under the dialog tier's top layer: promote the toast to a
  manual popover only while a modal dialog is open (popovers stack above
  later dialogs); attribute dropped after so the everyday fade survives.
- Ambient glow now follows the kind accent (cyan/red surfaces no longer
  bloom amber); skill editor's content column pins sticky so the SKILL.md
  pane stays visible while the meta column scrolls; MCP-detail stacking
  re-keyed from viewport to @container pane; dark-body micro-text and the
  origin badge brought up to the contrast floor; quiet foot actions
  (Validate regex, Detect) lifted from body-copy gray.
2026-06-10 13:31:53 -07:00
Patrick Buckley 69fb8c3140 fix(console): help popover consumes its Escape — layered shelf dismissal
With the popover and a shelf both open, Escape closed both at once
(admin.js's popover listener and hatch.js's shelf listener each fired).
The popover listener registers at parse time — always ahead of hatch.js's
first openShelf — so stopImmediatePropagation makes Escape peel one layer
at a time: popover first, shelf on the next press.
2026-06-10 13:31:53 -07:00
Patrick Buckley d896108ae0 chore(console): tear down the legacy modal machinery — the shelf sweep is total
With the skill editor converted, nothing renders through the legacy
overlay system any more. Grep-driven deletion:

- admin.js: _modalFocusTrap/_installTrap/_removeTrap, the govOverlays
  dispatch table, and the global Escape handler die. The handler's one
  still-live block — closing an open settings-help popover — is
  extracted into its own small keydown listener (the settings panels
  and the shelf form help buttons share that component).
- governance.js: the orphaned template trap/trigger let declarations.
- style.css: the .admin-modal box rules (incl. -wide/-skill), every
  .admin-modal-prefixed half of the doubled toggle/segmented/perm/
  user-roles selectors (the unscoped twins keep serving the settings
  panels and shelf bodies), .admin-details (the shelf uses
  details.rawhatch), .modal-buttons/.modal-cancel/.modal-submit,
  .modal-section-divider, hr.toggle-group-divider, the now-empty
  #…-template-overlay ID rule, and their reduced-motion entries.
  Stale comment pointers re-aim at the live rules (.sh-alert,
  .sh-body label).

Kept with reason: .modal-columns/.modal-col* — _openMcpDetail still
builds the MCP-detail shelf body with them (their narrow-viewport
stacking rules survive in a rebuilt media block).

Livepass re-verified after the teardown: zero console errors across
create/edit/locked, and the unlock-confirm-over-shelf capture is
pixel-identical to the pre-teardown one.
2026-06-10 13:31:53 -07:00
Patrick Buckley ecef600c0b feat(console): skill editor onto the xl shelf — create/edit merge, lock in the head
The last legacy modal pair (create-template + edit-template, ~430 lines
per mode) collapses into one pane-scoped 920px shelf: a hidden skl-id
decides POST vs PUT, the duplicated ctm-/etm- field sets merge into one
skl-/sklc- set, and the skill-spec two-column grid transplants whole into
the scrolling body (content-area rule re-scoped under .sh-body so the
shelf's font:inherit/min-height cadence doesn't flatten it; the mobile
breakpoint becomes a pane container query to match the shelf's own
bottom-sheet degradation).

Everything judgment-bearing carries over: paste-to-parse (now cancelled
via the shelf's onClose so Escape/scrim dismissals abort the inflight
parse too), live {{variable}} detection (re-dressed as match-strip chips
under the textarea, count mirrored into the foot meta when provenance
isn't occupying it), pending-resource rows in create, server-backed
resources + security scan + re-scan in edit, and the runtime-config
field set that stays editable on readonly skills (must keep matching
SKILL_RUNTIME_CONFIG_FIELDS). The readonly lock affordance moves into
the head strip left of the designation plate as a ghost icon button;
origin/locked provenance renders in the foot meta lane (installed/
customized chip + source URL + 'locked — unlock to edit'); the unlock
confirm stacks above the shelf via the native top layer and the
post-unlock re-render mutates the open shelf in place. Progressive
disclosure keeps its <details> semantics on rawhatch chrome, with the
shared-DOM state leaks (disabled spec fields, expanded details) re-armed
on every create open.

Mode-exclusive blocks keep their ctm-/etm- ids (pending vs server
resources, scan) — only true duplicates merged. Inline onclick wiring
moves to a one-shot _skillShelfWire; submits go busy via the shelf LED
lock. The legacy overlay markup and the .skill-lock-btn / .skill-vars-*
rules are deleted; trap machinery teardown follows separately.

Livepass-verified (stubbed authFetch, headless Chrome): create, edit
populated, locked view (disabled spec + editable runtime + scan +
readonly resources), unlock confirm stacked over the shelf, paste
auto-fill, variable chips, pending-resource add/remove.
2026-06-10 13:31:53 -07:00
Patrick Buckley 14f159ccdf chore(console): drop the policy pilot's orphaned trap declarations
_cpTrapHandler/_epTrapHandler lost their last readers when the policy
surface moved onto the shelf (f609911b) — declaration-only since.
2026-06-10 13:31:53 -07:00
Patrick Buckley ddcf337883 feat(console): memory detail onto the inspect shelf
The memory-detail read-out leaves its body-level overlay for a pane-scoped
inspect shelf (#memory-detail-shelf, cyan, lg) inside #admin-layout. The
detail-grid + content population carries over verbatim; the foot keeps the
Delete action (now a danger sh-btn) wired after the record loads, beside Close.
showMemoryDetailModal/hideMemoryDetailModal keep their names — the row renderer
calls them — and run through window.TurnstoneHatch; the post-delete close check
keys off the dialog's .open. Legacy overlay markup, its _installTrap/Escape
dispatch entries, the trap/trigger lets, and its style.css overlay ID-list row
are deleted.
2026-06-10 13:31:53 -07:00
Patrick Buckley e54bdd1bc6 feat(console): prompt-policy + heuristic-rule + output-guard onto the shelf
The three governance create+edit pairs collapse onto pane-scoped lg shelves
inside #admin-layout. Each merges into ONE shelf with a hidden id (plus a
builtin flag on the judge surfaces) deciding the write: PUT for a DB row, an
override-POST for a built-in's first edit, a plain POST for a new row.
Title/tag/data-kind/submit-label flip between create and edit; edit-only chrome
(the prompt-policy Enabled toggle) sits in a hidden-toggled row.

The heuristic-rule and output-guard editors lift out of the Judge tab panel to
sit as direct children of the hatch-host (the shelf's inert containment needs
that); the two built-in-edit entry points share a single populate-and-open
helper. The settings-help-popover buttons carry over verbatim — their document-
delegated toggle is independent of the container. The output-guard "Validate
regex" button moves to the foot as a quiet action while its result strip stays
in the body. Submits go busy via the shelf LED lock; the regex flags input and
the is-credential toggle move onto the field grid. Legacy overlays, their
_installTrap/Escape dispatch entries, the trap-handler/trigger lets, and their
rows in the style.css overlay ID list are deleted.
2026-06-10 13:31:53 -07:00
Patrick Buckley e6cf2c9346 feat(console): roles + user-roles + github-import onto the shelf
The governance role surfaces leave their legacy overlays. Create + edit role
collapse into ONE pane-scoped shelf (#role-shelf inside #admin-layout): a hidden
role-id decides POST vs PUT, and title/tag/data-kind/submit-label flip between
"New role"/ROLE-NEW/create/Create and "Edit role — name"/ROLE-EDIT/edit/Save.
The slug-name row is create-only (hidden attr on edit); the display name carries
over and is disabled for builtin rows. The permission checkbox grid renders with
one "role" prefix for both modes — the builtin baseline-vs-rendered diff that
produces {grant, revoke} (and round-trips unknown perms untouched) is preserved
verbatim. Submit goes busy via the shelf LED lock instead of the disable dance.

User-roles becomes an edit shelf carrying its toggle-list population; github-
import a create shelf with the URL as an sh-mono field and the hint folded into
a label-hint span. The public show/hide/submit names the toolbars and row
renderers call are kept — only the bodies are rewired through window.TurnstoneHatch
(handler-time, never at parse time). Legacy overlays, their _installTrap/Escape
dispatch entries, the trap-handler/trigger lets, and their rows in the style.css
overlay ID list are deleted.
2026-06-10 13:31:53 -07:00
Patrick Buckley f2d76bbec6 feat(console): the MCP family onto the shelf — create/edit merge, detail, import, install
The four MCP surfaces leave their legacy overlays. The add/edit server editor
collapses into ONE lg pane-scoped shelf (#mcp-shelf): a hidden mcp-edit-id
decides POST vs PUT, and the title/tag/data-kind/submit-label flip between
"Add MCP server"/MCP-NEW/create/Create and "Edit MCP server — name"/MCP-EDIT/
edit/Save. The transport-conditional stdio/http field groups and the OAuth
subfield block toggle on the hidden attribute instead of style.display (which
.hatch [hidden] enforces); the multitenant-auth segmented radio control carries
over verbatim. The transport/auth onchange and submit move out of inline markup
into _mcpWire (which also installs the audience-autofill listener once).

mcp-import becomes a create shelf with the JSON paste as an sh-mono textarea;
mcp-detail an inspect shelf carrying its setSafeHtml two-column population. The
registry install flow moves onto the document-modal dialog tier per the confirm
precedent: a STATIC #mcp-install-dialog whose summary/source/fields containers
_showInstallMcpModal still populates — the dynamic overlay-shell construction is
gone. _doRegistryInstall is shared by the one-click card path and the dialog
submit, so its busy lock and inline-vs-toast error branch now key off the
dialog's .open. Submits go busy via the LED lock; legacy trap/Escape/ID-list
entries and the trap-handler lets are deleted.
2026-06-10 13:31:53 -07:00
Patrick Buckley 4adf223dd9 feat(console): user/token/channel/runs onto the shelf — the admin.js simple forms
The four admin.js leaf surfaces follow the schedules pilot onto pane-scoped
shelves inside #admin-layout: create-user, create-token, link-channel and the
read-only schedule run history. Each keeps the public show/hide/submit names the
toolbars and row renderers already call (showCreateUserModal, showScheduleRuns,
…) — only the bodies are rewired. Open/close run through window.TurnstoneHatch
(handler-time, never at parse time); submit goes busy via the shelf LED lock
instead of the disable/relabel button dance; errors land in the sh-alert.

The token shelf hands its issued secret to the already-converted token-created
dialog unchanged. Schedule runs is an inspect shelf (cyan, lg width, Close-only
foot) and carries its setSafeHtml run-table population verbatim. The channel
type→placeholder onchange moves out of inline markup into _channelWire. Legacy
overlays, their _installTrap/Escape dispatch entries, the trap-handler lets, and
their rows in the style.css overlay ID list are deleted.
2026-06-10 13:31:53 -07:00
Patrick Buckley 0e0532eee5 feat(console): policies pilot + the dialog tier — confirm/token-created go native
Tool policies join the shelf: one pane-scoped editor for create+edit with a
priority-neighbor read-out computed from the loaded policy list ('evaluates
after deny-rm (900) · before default-ask (0)' — policies run highest-first),
so where a priority lands answers itself while typing. Live tool-pattern
match chips are deferred until a cluster tool-registry endpoint exists.

The reusable confirm and the show-once token dialog move onto the
document-modal hatch tier (native showModal): the confirm keeps its
showConfirmModal(title, message, actionLabel, callback) contract for all
14 call sites, gains the danger chrome (red LED/hairline/title + err-filled
action), and deliberately moves autofocus from the action button to Cancel
— Enter on a fresh destructive confirm no longer fires the action. The
token dialog gets the success chrome + show-once callout, with copy wired
to the primary. Nested confirm-over-shelf now stacks via the top layer;
the z-index 650 special case and four more overlay-ID/dispatch entries die.

Also fixes a real war the livepass caught: display rules on form chrome
(label.toggle-switch's inline-flex) defeat the hidden attribute — hatch
containers now enforce [hidden] with display:none !important.
2026-06-10 13:31:53 -07:00
Patrick Buckley eba53edd36 feat(console): models pilot — capabilities JSON becomes an LED tile matrix
The model editor moves onto a lg shelf and the hand-written capabilities
JSON requirement dies. Nine LED tiles (tools/streaming/vision/web-search/
temperature/effort/STT/TTS/reranker) display merge(dataclass defaults,
known-model table, explicit overrides) with SPARSE-OVERRIDE persistence:
only keys saved in the row or toggled by the operator are written back, so
known models keep tracking future capability-table updates instead of
being pinned at save time. The known-model lookup that previously dumped
the whole table into the textarea becomes the tile BASELINE refresher with
a provenance banner ('Loaded from the built-in table for X'); the raw JSON
survives as a collapsed advanced hatch holding everything the tiles don't
manage (thinking_display, max_output_tokens, …). supports_rerank is now a
tile — no more hand-JSON to flag a reranker — and drives the Re-calibrate
button + calibration chip (now a foot read-out next to quiet Detect).

Everything regression-prone carries over: server_compat extraction,
write-only api_key sentinel, reranker calibration field re-merge (raw-typed
keys still win), thinking-mode representability guard, detect/calibrate
flows. Inline onclick/onchange wiring moves to the boot IIFE; busy locks
the shelf LED instead of disabling the button. Legacy overlay markup, the
trap/Escape dispatch entries, and the CSS ID-list entry are deleted.

Livepass-verified (stubbed authFetch, headless Chrome): create + edit,
baseline banner, tile extraction, toggle-switch inside .sh-body.
2026-06-10 13:31:53 -07:00
Patrick Buckley 425c38dc0e feat(console): schedules pilot — the cron DSL becomes a builder on the shelf
First production surface on the service-hatch shelf. Create + edit collapse
into ONE pane-scoped dialog (#schedule-shelf inside #admin-layout, now the
.hatch-host): a segmented Runs control (Daily/Weekly/Monthly/Interval/Once/
Cron) compiles to schedule_type/cron_expr/at_time — nobody types cron unless
they choose Cron mode, which keeps the raw input as the escape hatch with
the same live read-out. The NEXT RUNS read-out previews the next three
firings through the server's croniter (debounced POST /schedules/preview),
and the foot strip always shows the compiled expression for verification.

Storage is untouched: on edit the saved expression is reverse-parsed back
into the friendly mode when its shape matches (_cronToScheduleMode), else
the editor opens in Cron mode. Notify-row/select-populate helpers carry
over verbatim; submit goes busy via the shelf LED lock instead of button
text swapping. The legacy create/edit overlays, their show/hide/toggle
globals, hand-rolled trap wiring, and their entries in the overlay ID list
and _installTrap/Escape dispatch tables are deleted (-242 lines of markup).
Toggle-switch gets its .sh-body exception in hatch.css (the same
specificity war .admin-modal fights), + .sh-mono utility.

Verified against a stubbed-authFetch livepass harness in headless Chrome:
create/edit/light/busy, weekly reverse-parse round-trip, preview rendering.
2026-06-10 13:31:53 -07:00
Patrick Buckley 5b0255f468 feat(ui): service-hatch container system — pane-scoped shelf + modal dialog tier
One chrome vocabulary (machined head/foot strips on --code-bg, kind LED,
designation plate, center-fading hairline), two mounting points:

- .hatch--shelf: pane-scoped, NON-modal (dialog.show()). Mounts inside the
  pane's .hatch-host, docks right, dims only that pane via a lazy sibling
  .pane-scrim, and contains focus with inert on the pane's other children —
  rail/tabs/other panes stay live. Split panes work by construction; the
  bottom-sheet degradation is an @container query on the pane, not a
  viewport media query, so a narrow split degrades too. Controller-owned
  Escape defers to any document-modal dialog stacked above.
- .hatch--dialog: document-modal (showModal()) confirm/show-once tier;
  top layer stacks it above any shelf with no z-index ladder.

Smart-input primitives ship alongside (seg, chips, readout, capgrid,
match-strip, rawhatch, autofill) for the Phase-1 surfaces. data-busy locks
the container while a submit is in flight (LED pulses, dismissal refused).
Light-theme micro-text gets the .tab-menu-key one-step-up contrast pass.
Classic scripts reach the ESM controller via the window.TurnstoneHatch
bridge (toast.js pattern, handler-time only); invariants pinned in
tests/test_hatch_js.py incl. a markup-shape lint over dialog.hatch.
2026-06-10 13:31:53 -07:00
Patrick Buckley f086e38e37 feat(console): schedule preview endpoint — next-3-runs read-out backend
POST /v1/api/admin/schedules/preview validates {schedule_type, cron_expr,
at_time} with the same _validate_schedule_fields the CRUD path uses and
returns the next three croniter firings. Pure compute, no storage touch;
invalid input answers 200 {valid:false, error} because the schedule
editor renders it live while the user types. Registered ahead of the
{task_id} routes so the literal segment wins.
2026-06-10 13:31:53 -07:00
Patrick Buckley 8f415f9c68 docs(judge): say llm_fallback, not 'heuristic fallback', for cancelled items
Review feedback: the cancel-path docstrings described undone items as
degrading to 'heuristic fallback verdicts', but the emitted and
persisted tier is llm_fallback (heuristic content relabeled). Aligned
all eight occurrences — including the pre-existing _deliver_fallbacks
docstring — so docs, logs, and audit rows use one vocabulary.
2026-06-09 20:00:55 -07:00
Patrick Buckley b24b029c4d refactor(storage): single-source the bulk-insert race rationale
Review follow-up: the ON CONFLICT rationale lived verbatim in three
places (protocol docstring + both backend comments). Keep the prose in
the protocol — the contract's home — and point the backends at it.
Also recommend cancel_on_approval=true in docs for deployments where
the judge shares one local inference backend with the session model.
2026-06-09 20:00:55 -07:00
Patrick Buckley c75afd704b docs(judge): document verdict lifecycle — run-to-completion, superseded rows, replay parity 2026-06-09 20:00:55 -07:00
Patrick Buckley fb282fab73 fix(judge): honor the cancel_on_approval=False run-to-completion contract
judge.cancel_on_approval=False (the default) promises the daemon
evaluates every tool call to completion so all verdicts are available
for later review. Two sites conspired to break that: the approval
gate's finally set the cancel event unconditionally the moment a
decision landed, and _evaluate_single's poll loop honors the event
regardless of config — so every item the sequential judge hadn't
reached degraded to a heuristic llm_fallback row. On a 22-call
parallel batch, approving after the third verdict silently downgraded
the other 19; the elaborate late-verdict machinery in
on_intent_verdict was effectively dead code.

Make the event a pure abort signal whose firing policy lives with the
caller: the gate fires it only when cancel_on_approval is enabled,
while generation supersede (next batch) and close() keep firing it
unconditionally, bounding a stale daemon to one batch of real work.
_run_judge drops its own config second-guessing — a fired event always
fast-forwards the remainder to fallbacks (every call still gets
exactly one verdict), and the fallback reason no longer claims 'user
approval' for supersede/close aborts.
2026-06-09 20:00:55 -07:00
Patrick Buckley d0cede5e13 fix(storage): per-row conflict tolerance on bulk verdict insert
The async judge daemon can UPSERT a fallback row — reusing a heuristic
verdict_id from the batch approve_tools is about to bulk-insert —
before the bulk write runs. With a plain INSERT, that single PK
collision aborted the entire statement, and the caller's best-effort
try/except silently discarded every heuristic row in the batch.

Insert ON CONFLICT (verdict_id) DO NOTHING on both backends: siblings
survive a mid-batch collision, and the colliding row keeps the
daemon's llm_fallback tier upgrade instead of regressing to the
heuristic stamp (the documented preferred outcome). Regression test
runs against both storage backends via --storage-backend.
2026-06-09 20:00:55 -07:00
Patrick Buckley effdb8f365 fix(judge): persist superseded late verdicts for the audit trail
ChatSession._on_verdict guards on judge-generation identity so a stale
verdict can't ride a reused call_id into the Smart-Approvals cache —
but it dropped those verdicts entirely, before persistence. Every
ruling the sequential judge delivered after the next turn began left
intent_verdicts claiming the judge never answered.

Route superseded verdicts to a new persist-only hook
(SessionUIBase.on_superseded_intent_verdict): the row lands with
user_decision="superseded" while every live surface stays untouched
(no SSE, no replay cache, no pending-decision park). The hook is
duck-typed; display-only UIs (CLI/eval) don't define it and keep the
plain drop. upsert_intent_verdict already excludes user_decision from
its on-conflict SET, so a superseded fallback upgrading its heuristic
row in place cannot clobber a decision already stamped there.
2026-06-09 20:00:55 -07:00
Patrick Buckley 461d01f72a fix(ui): ship risk-none intent verdicts on history replay
The /history decoration layer suppressed intent-verdict rows with
risk_level="none" from the wire payload, on the assumption the client
filtered them anyway. It never did: buildConvVerdict renders a badge
for every verdict it receives, so the live SSE path painted all judge
verdicts while rehydration silently dropped the benign majority — a
22-call parallel batch came back from a restart showing only the 3
flagged calls.

Ship every stored row and let the client render replay exactly as it
rendered the live stream. The output-guard chip pair (showOutputWarning
+ merge-on-clean) already suppresses consistently on BOTH sides and is
unchanged.
2026-06-09 20:00:55 -07:00
Patrick Buckley 9fefe830d1 fix(tls): validate init() retry params
attempts < 1 made init() return successfully without fetching CA or
cert — a silent no-op leaving the client uninitialized. Fail fast with
ValueError instead; negative base_delay rejected on the same guard.
2026-06-09 18:37:46 -07:00
Patrick Buckley a3ff07a86d fix(tls): mTLS-aware container healthcheck + boot-time init retry
A whole-stack restart races every node against the console for the CA
fetch (compose re-enforces depends_on ordering only on `up`): losers
logged one warning and served plain HTTP for their lifetime, while
winners served mTLS that the plain-HTTP container healthcheck could
never probe — leaving "healthy" plaintext nodes and "unhealthy"
working ones.

- TLSClient.init() grows attempts/base_delay retry (server passes 6
  attempts, ~31 s backoff) absorbing the boot race; per-attempt CA-fetch
  failures log warning + debug traceback instead of error tracebacks.
- healthcheck.py falls back to HTTPS when the plain probe fails,
  presenting the node's own cert as the client cert with the cluster CA
  pinned; dials localhost because the internal CA issues DNS SANs only.
  Default plain-HTTP deployments are unchanged.
- The server writes boot PEMs under a fixed root (TURNSTONE_TLS_PEM_DIR,
  default <tmpdir>/turnstone-tls) so the probe can find them; boot
  clears stale dirs and refuses a symlinked/foreign-owned root; renewal
  rewrites the PEM dir so the probe's client cert never outlives the
  served cert.
- /health reports tls: "active"|"fallback" (absent when TLS is
  disabled) so a silently downgraded node is observable.
2026-06-09 18:37:46 -07:00
84 changed files with 11769 additions and 6823 deletions
+200 -58
View File
@@ -6,81 +6,223 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [PEP 440](https://peps.python.org/pep-0440/) for
version numbers (`X.Y.Z`, with `X.Y.ZaN` / `bN` / `rcN` for pre-releases).
Three release tracks are maintained:
Three release tracks are maintained — the current stable, one prior
stable, and the experimental line:
- **`stable/1.4`** — patch-only (`v1.4.x`)
- **`stable/1.5`** — patch-only (`v1.5.x`)
- **`stable/1.6`** — patch-only (`v1.6.x`)
- **`main`** — experimental (next major)
## [Unreleased]
## [1.6.0]
The first stable release of the 1.6 line — and the first under Apache 2.0.
> **⚠️ Before upgrading from 1.5.x:** 1.6.0 changes the internal
> conversation storage schema (Alembic migration `060`, applied
> automatically on first start). The migration converts existing
> workstreams and attachments in place — **back up your storage before
> upgrading** (`pg_dump` for PostgreSQL; copy the database file for
> SQLite). Background: discussion
> [#631](https://github.com/turnstonelabs/turnstone/discussions/631).
**Breaking changes at a glance** (details in the sections below):
`web_search` backend overhaul (Tavily/DuckDuckGo removed, `topic`
`category`), the `man` / `math` / `plan_agent` built-in tools and the
plan-review protocol removed, and the body-keyed `/v1/api/command`
endpoint replaced by path-keyed workstream verbs.
### License
- **Relicensed to Apache 2.0** — from BUSL-1.1, effective with this
release (#546, contributor assent record in #548). Versions 1.5.x and
earlier remain under BUSL-1.1 as shipped, and the `stable/1.5` branch
keeps its original LICENSE. New `NOTICE` and
`CONTRIBUTORS.md` files; `THIRD-PARTY-NOTICES` refreshed to match the
bundled library versions.
### Added
- **Self-hosted SearxNG web search** — the `web_search` tool's backend for
local/vLLM models is now a bundled [SearxNG](https://searxng.org) service
(`searxng` in both compose stacks; internal docker network only, JSON API
enabled, rate limiter off). Two new settings configure it: `tools.searxng_url`
(default `http://searxng:8080`, env `TURNSTONE_SEARXNG_URL`) and
`tools.searxng_engines` (env `TURNSTONE_SEARXNG_ENGINES`). Commercial providers
(Anthropic, OpenAI) continue to use their own native server-side search and
never touch SearxNG; the `mcp:server:tool` backend is unchanged. A persistent
`searxng-cache` volume keeps its favicon/internal cache across restarts, and
Caddy can serve SearxNG's own web UI on a dedicated port (dev stack:
`https://localhost:8444`, localhost-only; production: opt-in). See
[docs/docker.md](docs/docker.md) for the AGPL-3.0 §13 note that applies to
operators who expose the bundled SearxNG publicly.
- **`turnstone-admin` reads `config.toml`** — the admin CLI now honors
the same `[database]` section that `turnstone-server` does, with the
same precedence (`CLI / config.toml > TURNSTONE_DB_* env > defaults`).
Operators with DB credentials in `config.toml` no longer need to
re-export `TURNSTONE_DB_URL` before every admin invocation. Newly
plumbed through to `init_storage`: `pool_size`, `sslmode`,
`sslrootcert`, `sslcert`, `sslkey` — previously the admin CLI
silently dropped these. A new `--config PATH` flag mirrors the
one already on `turnstone-server`.
- **Mid-conversation system messages** — advisories, watch results,
skill hints, and operator interjections are now first-class
`role=system` turns in the trajectory instead of ad-hoc reminder
envelopes. Models with native mid-conversation system support receive
them verbatim; for everything else they fold into a nonce-fenced
wrapper. The one-shot `_reminders` side-channel is gone.
- **Self-hosted SearxNG web search** — the `web_search` backend for
local/vLLM models is now a bundled [SearxNG](https://searxng.org)
service (in both compose stacks; internal network only). Configure via
`tools.searxng_url` / `tools.searxng_engines`. Commercial providers
keep their native server-side search; the model can target a corpus by
passing `category` (`general`, `news`, `it`, `science`). Operators
exposing the bundled SearxNG publicly: see the AGPL-3.0 §13 note in
[docs/docker.md](docs/docker.md).
- **Endpoint-backed reranking** — a reranker is now a per-model
definition (Cohere/Jina-compatible wire: vLLM, TEI, llama.cpp, or a
commercial endpoint), disabled by default. When configured it scores
`web_search` results and the BM25 retrieval surfaces (deferred tools,
skills, memory) behind a `tools.rerank_bm25` toggle with a relevance
floor; a calibration CLI (and calibrate-on-detect) tunes the floor
per model.
- **Proactive memory relevance** — injected memories are selected by
BM25 + reranker against the recent user messages instead of recency
alone, and first composition defers to the first user turn so fresh
sessions select against a real query.
- **Smart Approvals** — opt-in (default off): high-confidence `approve`
verdicts from the intent judge auto-approve the tool call instead of
waiting for a human, with a confidence threshold and verdict
bookkeeping designed so a denied or reset judge never auto-fires.
- **Early-painted tool calls** — committed tool calls render immediately
as pending cards (both UIs upgrade the card in place by `call_id`)
instead of waiting for the judge verdict, so big parallel batches no
longer sit invisible during judging.
- **Voice I/O v1** — speech-to-text and text-to-speech as model roles
speaking the OpenAI audio wire protocol (#618); the interactive
composer grows a mic button.
- **Rewind / retry / edit-first-message** — full UX in both the
interactive UI and the coordinator pane, backed by shared path-keyed
verb handlers (#549).
- **Workstream export** — download a conversation as OpenAI-format
messages JSON.
- **Skills platform round** — `SKILL.md` ingestion learns
`when_to_use` / `model` / `effort` / `paths`; prompt substitution
supports `$ARGUMENTS`, `$N`, `$<name>`, and `${CLAUDE_*}` (#572);
per-skill `disable-model-invocation` and `user-invocable` flags
(#571); `skill` + `list_skills` unify into one dual-kind tool; new
`model.skills.write` permission.
- **Coordinator hardening for small models** — workstream references in
coordinator tool calls are validated with did-you-mean recovery, and
`wait_for_workstream` fails fast with uniform `not_found` entries
instead of hanging on a hallucinated `ws_id`.
- **Provider support** — Claude Fable 5 and Claude Opus 4.8; xAI/Grok
via the OpenAI Responses lane; vLLM reasoning-field replay completes
the reasoning-persistence work (#537).
- **Cluster-by-default deployment** — the compose stack fronts
everything with Caddy and supports bare-metal node join; a one-line
`curl | bash` installer bootstraps a node; nodes with no configured
models boot into a degraded state instead of crash-looping; channel
gateways stand by when no adapter token is set.
- **MCP OAuth tokens encrypted at rest**.
- **`turnstone-admin` reads `config.toml`** — same `[database]` section
and precedence as the server (`CLI / config.toml > TURNSTONE_DB_* env
> defaults`), including `pool_size` and the `ssl*` knobs it previously
dropped; new `--config PATH` flag.
### Changed
- **Conversation storage and the provider wire are rebuilt around a
canonical trajectory** (migration `060` — see the upgrade note).
Internally a conversation is now a provider-neutral `Turn` sequence
lowered to each provider's wire format at send time; provider-specific
tool-call metadata rides an opaque producer-tagged lane (replayed
verbatim to the producing provider, rebuilt for others); attachments
become content-addressed, reference-counted rows resolved at the
provider boundary; orphan tool-call repair happens once, at send time.
Wire-visible behavior is unchanged for OpenAI-compatible providers;
histories are preserved across the migration.
- **The console and web UI share one L-shell** — a left glyph rail, a
tab bar, and a pane host now frame interactive chats, coordinator
sessions, dashboards, and the admin panel as tabs in a single window;
the standalone web UI adopts the same shell and the old split-pane
layout is retired. Coordinator and interactive conversations render
through shared `.conv-*` card builders, the rail collapses to a glyph
strip (remembered per browser), mobile gets an off-canvas drawer, and
the frontend is now ES modules end to end.
- **Admin panel modals → the Service Hatch shelf** — all ~35 admin
modals are replaced by pane-scoped shelves plus a small dialog tier
for confirmations. Schedules gain a cron builder with a next-3-runs
preview endpoint, model capabilities render as an LED tile matrix, and
the legacy modal machinery is deleted.
- **SSE delivery is resumable end to end** — per-workstream ring buffer
with `Last-Event-ID` replay (cap raised 2,000 → 50,000), fresh-connect
and reconnect unified on one event-id cursor (in-flight tool batches
included), persisted `last_error` replays on connect, the console
proxy forwards `Last-Event-ID`, and panes close their connections on
`beforeunload` to stop multi-pane refresh from exhausting the
browser's per-host connection cap (#539).
- **Workstream verbs are path-keyed** *(BREAKING)*`rewind` / `retry`
/ `edit-first-message` live at
`/v1/api/workstreams/{ws_id}/<verb>` alongside the other session
verbs; the body-keyed `/v1/api/command` endpoint is removed (#549).
- **`/history` is projected server-side** — both UIs consume the same
REST-first wire shape instead of re-deriving it client-side.
- **Saved workstreams & coordinators: card grid → sortable table** with
model/skill/context columns, pagination, and a unified selector across
both dashboards.
- **`tools.web_search_backend` accepted values** *(BREAKING)* — now `""`
(auto), `"searxng"`, or `"mcp:server:tool"`. The old `"tavily"` and `"ddg"`
values are gone; a config still set to either disables web search and logs a
warning. Auto-detect resolves to SearxNG when `searxng_url` is set, otherwise
no client (the `web_search` tool is dropped for models without native search).
- **`web_search` tool: `topic``category`** *(BREAKING)*the LLM-facing
parameter is renamed and its values are now `general` (default), `news`, `it`
(code/tech), or `science`, mapped to SearxNG categories so the model can target
the right corpus. The Tavily-era `finance` topic (no SearxNG equivalent) is gone.
(auto), `"searxng"`, or `"mcp:server:tool"`. The old `"tavily"` and
`"ddg"` values are gone; a config still set to either disables web
search and logs a warning. Auto-detect resolves to SearxNG when
`searxng_url` is set.
- **`web_search` tool: `topic``category`** *(BREAKING)*renamed
LLM-facing parameter; values map to SearxNG categories. The Tavily-era
`finance` topic is gone.
- **Core install includes what most deployments use** — `anthropic`,
`postgres`, `console`, and `tls` are core dependencies rather than
extras.
- **NODES table → bottom-bar node picker** in the console.
### Fixed
- **Cluster mTLS actually survives operations** — certificate identity
keys on the advertised host rather than the container ID, renewals are
scoped per node, reloaded certs hot-swap into the live SSL context,
and healthchecks/boot retries are mTLS-aware.
- **Intent-verdict lifecycle** — history replay ships risk-none verdict
rows (live/replay parity), late verdicts persist as `superseded` for
the audit trail instead of vanishing, bulk verdict insert tolerates
per-row conflicts, and cancel-on-approval honors its run-to-completion
contract.
- **Usage accounting** — dashboard totals were under-counting; auxiliary
LLM spend (judge, rerank, memory) is now recorded.
- **Concurrent first-boot migrations** no longer deadlock on the
advisory lock.
- **Output renderer** — single-`$` inline math no longer false-positives
in prose; `strip_html` preserves block structure and drops a ReDoS
risk.
- **Model registry** orders versions numerically (no more `1.10 < 1.9`
selection).
### Removed
- **Tavily and DuckDuckGo `web_search` backends** *(BREAKING)* replaced by the
bundled self-hosted SearxNG service (see Added). Removed: the
`tools.tavily_api_key` setting, the `$TAVILY_API_KEY` env var, the
`[api].tavily_key` config key, and the `ddg` install extra (the `ddgs`
dependency). Migration: use the bundled SearxNG (it ships in the compose stacks
by default) or point `TURNSTONE_SEARXNG_URL` at an existing instance. No
database migration required.
- **`man`, `math`, and `plan_agent` built-in tools removed** — `man` and
`math` duplicated capabilities already available through `bash`; `plan_agent`
is better expressed as a `task_agent` running a planning skill. Removing
them simplifies the tool surface and cuts per-call token cost. This release
also removes: the `math` sandbox executor (`turnstone.core.sandbox`) and the
`[sandbox]` extra's role for it; the read-only `AGENT_TOOLS` sub-agent tool
set and the `agent` tool-metadata key; the plan-review protocol
(`/v1/api/plan` endpoint, `plan_review`/`plan_resolved` SSE events, the
`on_plan_review` SDK/UI hook); and the `model.plan_alias` /
`model.plan_effort` ConfigStore settings (and the corresponding
`[model].plan_model` / `[model].plan_effort` config.toml knobs).
**Breaking change** on the experimental 1.6 line. Interactive built-in tool
count moves from 19 → 16; `TASK_AGENT_TOOLS` from 13 → 11.
- **Tavily and DuckDuckGo `web_search` backends** *(BREAKING)*
replaced by the bundled SearxNG service. Removed:
`tools.tavily_api_key`, `$TAVILY_API_KEY`, `[api].tavily_key`, and the
`ddg` install extra. Point `TURNSTONE_SEARXNG_URL` at an existing
instance or use the bundled one; no database migration required.
- **`man`, `math`, and `plan_agent` built-in tools** *(BREAKING)*
`man`/`math` duplicated `bash`; planning is better expressed as a
`task_agent` running a planning skill. Also removed: the `math`
sandbox executor, the read-only `AGENT_TOOLS` sub-agent set, the
plan-review protocol (`/v1/api/plan`, `plan_review`/`plan_resolved`
SSE events, `on_plan_review` hooks), and the `model.plan_*` settings.
Interactive built-in tool count: 19 → 16.
- **`stable/1.4` track retired** — the maintenance policy is now the
current stable plus one prior (`stable/1.6` + `stable/1.5` as of this
release). 1.4's final release was `v1.4.0`; its tags and released
artifacts remain available, under BUSL-1.1 as shipped.
### Security
- **Permissive `config.toml` now warns** — `turnstone.core.config.load_config`
logs a single warning when the resolved config file is group- or
world-readable (any bit in `0o077`). DB password and TLS key paths
live in `[database]`; operators usually want the file at `0600`.
- **Zero direct-HTML frontend** — every `innerHTML` sink across the
console and web UI is replaced with DOM construction or `setSafeHtml`,
inline handlers became delegated bindings, and CI lints pin the
invariant (plus `var`-free and const-reassign checks) across all
swept bundles.
- **Output guard grows an LLM stage** — merged with the heuristics as
escalate-only (an LLM verdict can raise but never lower a heuristic
positive), with annotated findings, a capability gate, and hardening
against domain-camouflaged injection (#560, #573).
- **One trust-fence primitive** — operator and judge envelopes share a
nonce-fenced wrapper (64-bit nonces, host-escaping); the output guard
flags nonce forgery, and skill hints no longer echo model-controlled
filter values into trusted text.
- **RBAC** — built-in role overrides get an editor, and several
under-enforced permission gates are tightened (#585).
- **Permissive `config.toml` warns** — a single startup warning when the
resolved config file is group- or world-readable; operators usually
want `0600`.
- **Dependency floors** — `starlette>=1.0.1` (PYSEC-2026-161 host-header
path injection) and `aiohttp>=3.14.0` (security release).
## [1.5.17]
+1 -1
View File
@@ -56,4 +56,4 @@ Open an issue at https://github.com/turnstonelabs/turnstone/issues with:
## License
By contributing, you agree that your contributions will be licensed under the
project's [Business Source License 1.1](LICENSE).
project's [Apache License 2.0](LICENSE).
+13
View File
@@ -0,0 +1,13 @@
# Contributors
Turnstone is written and maintained by Patrick Buckley
([@eous](https://github.com/eous)).
The following people have contributed code to the project — thank you:
- Burhan ([@Burhan-Q](https://github.com/Burhan-Q))
- chrismuzyn ([@chrismuzyn](https://github.com/chrismuzyn))
- daoxley ([@daoxley](https://github.com/daoxley))
- Robert DeAngelis ([@OriginalOrangeXD](https://github.com/OriginalOrangeXD))
- William ([@sillyWillieBilly](https://github.com/sillyWillieBilly))
- [@pizzaandcheese](https://github.com/pizzaandcheese)
+2 -2
View File
@@ -8,7 +8,7 @@ FROM python:3.14-slim
LABEL org.opencontainers.image.title="turnstone" \
org.opencontainers.image.description="Multi-node AI orchestration platform"
COPY --from=ghcr.io/astral-sh/uv:0.11.19 /uv /usr/local/bin/uv
COPY --from=ghcr.io/astral-sh/uv:0.11.21 /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
@@ -33,7 +33,7 @@ RUN useradd --create-home --shell /bin/bash turnstone
WORKDIR /app
# Install dependencies first (cached layer — only re-runs when deps change)
COPY pyproject.toml uv.lock README.md LICENSE ./
COPY pyproject.toml uv.lock README.md LICENSE NOTICE THIRD-PARTY-NOTICES ./
RUN uv sync --frozen --no-install-project --no-dev \
--no-compile --extra all
+187 -48
View File
@@ -1,62 +1,201 @@
License text copyright (c) 2020 MariaDB Corporation Ab, All Rights Reserved.
"Business Source License" is a trademark of MariaDB Corporation Ab.
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
Parameters
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
Licensor: Patrick Buckley
Licensed Work: Turnstone 0.2.0. The Licensed Work is (c) 2025-2026 Patrick Buckley.
Additional Use Grant: You may make production use of the Licensed Work, provided
your use does not include providing the Licensed Work to third
parties as a hosted or managed service, where the service
provides users with access to any substantial set of the
features or functionality of the Licensed Work.
Change Date: 2030-03-01
Change License: Apache License, Version 2.0
1. Definitions.
For information about alternative licensing arrangements for the Licensed Work,
please contact buckleypm@gmail.com.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
Notice
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
Business Source License 1.1
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
Terms
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
The Licensor hereby grants you the right to copy, modify, create derivative
works, redistribute, and make non-production use of the Licensed Work. The
Licensor may make an Additional Use Grant, above, permitting limited production use.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
Effective on the Change Date, or the fourth anniversary of the first publicly
available distribution of a specific version of the Licensed Work under this
License, whichever comes first, the Licensor hereby grants you rights under
the terms of the Change License, and the rights granted in the paragraph
above terminate.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
If your use of the Licensed Work does not comply with the requirements
currently in effect as described in this License, you must purchase a
commercial license from the Licensor, its affiliated entities, or authorized
resellers, or you must refrain from using the Licensed Work.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
All copies of the original and modified Licensed Work, and derivative works
of the Licensed Work, are subject to this License. This License applies
separately for each version of the Licensed Work and the Change Date may vary
for each version of the Licensed Work released by Licensor.
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
You must conspicuously display this License on each original or modified copy
of the Licensed Work. If you receive the Licensed Work in original or
modified form from a third party, the terms and conditions set forth in this
License apply to your use of that work.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
Any use of the Licensed Work in violation of this License will automatically
terminate your rights under this License for the current and all other
versions of the Licensed Work.
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
This License does not grant you any right in any trademark or logo of
Licensor or its affiliates (provided that you may use a trademark or logo of
Licensor as expressly required by this License).
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON
AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS,
EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND
TITLE.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
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.
+7
View File
@@ -0,0 +1,7 @@
Turnstone
Copyright 2025-2026 Patrick Buckley
Licensed under the Apache License, Version 2.0; see the LICENSE file.
Third-party software bundled with this distribution is listed in the
THIRD-PARTY-NOTICES file; each component remains under its own license.
+2 -2
View File
@@ -3,7 +3,7 @@
[![CI](https://github.com/turnstonelabs/turnstone/actions/workflows/ci.yml/badge.svg)](https://github.com/turnstonelabs/turnstone/actions/workflows/ci.yml)
[![PyPI](https://img.shields.io/pypi/v/turnstone)](https://pypi.org/project/turnstone/)
[![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)
[![License](https://img.shields.io/badge/license-Apache--2.0-blue)](LICENSE)
[![Discord](https://img.shields.io/badge/Discord-join%20us-5865F2?logo=discord&logoColor=white)](https://discord.gg/Nh3bWMacaq)
Self-hosted, local-first orchestration for tool-using AI agents. Give LLMs real tools — shell, files, search, web — and run them across your own cluster with direct HTTP routing and interactive interfaces. Your code, your models, your data stay on hardware you control: no telemetry, no phone-home.
@@ -169,4 +169,4 @@ Questions, ideas, or want to show what you're building? Join us on Discord:
## License
[Business Source License 1.1](LICENSE) — free for all use except hosting as a managed service. Converts to Apache 2.0 on 2030-03-01.
[Apache License 2.0](LICENSE), as of version 1.6.0. Versions 1.5.x and earlier remain under the Business Source License 1.1 they shipped with.
+4 -4
View File
@@ -2,11 +2,11 @@ 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.
Turnstone Apache-2.0 license does not apply to these components.
================================================================================
KaTeX 0.16.38
KaTeX 0.17.0
https://katex.org/
https://github.com/KaTeX/KaTeX
@@ -70,7 +70,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
================================================================================
Mermaid 11.13.0
Mermaid 11.15.0
https://mermaid.js.org/
https://github.com/mermaid-js/mermaid
@@ -98,7 +98,7 @@ SOFTWARE.
================================================================================
hls.js 1.6.15
hls.js 1.6.16
https://github.com/video-dev/hls.js
Copyright 2017 Dailymotion
+75 -11
View File
@@ -2,13 +2,69 @@
"""Health check for turnstone containers.
Usage: healthcheck.py <url>
Exit 0 if the endpoint returns {"status": "ok"}, exit 1 otherwise.
Uses only stdlib — no pip dependencies required.
Exit 0 if the endpoint returns {"status": "ok"} or {"status": "degraded"},
exit 1 otherwise. Uses only stdlib — no pip dependencies required.
When the node serves mTLS (tls.enabled), a plain-HTTP probe is rejected at
the socket, so on failure this script retries over HTTPS, presenting the
node's own certificate as the client cert and pinning the cluster CA. The
PEM files are the ones the server writes at boot under
$TURNSTONE_TLS_PEM_DIR (default: <tmpdir>/turnstone-tls). The host is
rewritten to "localhost" for the TLS attempt because the internal CA issues
DNS SANs only — certificate verification rejects a literal-IP dial.
When mTLS is disabled (the default), the plain probe succeeds and nothing
here changes: the PEM directory is never consulted.
"""
import json
import os
import ssl
import sys
import tempfile
import urllib.request
from pathlib import Path
from urllib.parse import urlsplit, urlunsplit
def _check(url: str, context: ssl.SSLContext | None = None) -> None:
"""Probe one URL; raise if unreachable or the payload is unhealthy."""
req = urllib.request.Request(url, method="GET")
with urllib.request.urlopen(req, timeout=5, context=context) as resp:
data = json.loads(resp.read().decode())
if data.get("status") not in ("ok", "degraded"):
raise RuntimeError(f"unhealthy payload: {data}")
def _pem_root() -> Path:
"""PEM runtime root.
Must mirror turnstone.core.tls.tls_pem_runtime_dir — this script is
standalone stdlib and cannot import turnstone; a drift-guard test in
tests/test_docker_healthcheck.py pins the two together.
"""
root_env = os.environ.get("TURNSTONE_TLS_PEM_DIR")
return Path(root_env) if root_env else Path(tempfile.gettempdir()) / "turnstone-tls"
def _find_pem_dir() -> Path | None:
"""Locate the newest complete PEM dir written by the server at boot."""
root = _pem_root()
candidates = [
d
for d in root.glob("lacme-pem-*")
if all((d / name).is_file() for name in ("fullchain.pem", "key.pem", "ca.pem"))
]
if not candidates:
return None
return max(candidates, key=lambda d: d.stat().st_mtime)
def _tls_url(url: str) -> str:
"""Rewrite scheme to https and host to localhost, keeping port and path."""
parts = urlsplit(url)
netloc = f"localhost:{parts.port}" if parts.port else "localhost"
return urlunsplit(("https", netloc, parts.path, parts.query, parts.fragment))
def main() -> None:
@@ -18,16 +74,24 @@ def main() -> None:
url = sys.argv[1]
try:
req = urllib.request.Request(url, method="GET")
with urllib.request.urlopen(req, timeout=5) as resp:
data = json.loads(resp.read().decode())
if data.get("status") in ("ok", "degraded"):
sys.exit(0)
print(f"Unhealthy: {data}", file=sys.stderr)
_check(url)
sys.exit(0)
except Exception as plain_exc:
pem_dir = _find_pem_dir()
if pem_dir is None:
print(f"Health check failed: {plain_exc}", file=sys.stderr)
sys.exit(1)
try:
context = ssl.create_default_context(cafile=str(pem_dir / "ca.pem"))
context.load_cert_chain(str(pem_dir / "fullchain.pem"), str(pem_dir / "key.pem"))
_check(_tls_url(url), context=context)
sys.exit(0)
except Exception as tls_exc:
print(
f"Health check failed: plain: {plain_exc}; mtls: {tls_exc}",
file=sys.stderr,
)
sys.exit(1)
except Exception as exc:
print(f"Health check failed: {exc}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
+58 -1
View File
@@ -702,7 +702,7 @@ agent_model = "claude"
Each `[models.*]` entry produces a `ModelConfig` with a `provider` field
(default: `"openai"`). Supported values: `"openai"`, `"anthropic"`, `"google"`,
and `"openai-compatible"`.
`"openai-compatible"`, and `"anthropic-compatible"`.
**Per-model sampling overrides:** Each model can specify `temperature`,
`max_tokens`, and `reasoning_effort` to override the global defaults from
@@ -765,6 +765,63 @@ model = "qwen-3.5-vl"
supports_vision = true
```
**Anthropic-compatible local servers (vLLM `/v1/messages`):** the
`"anthropic-compatible"` provider drives local servers that expose
Anthropic's Messages API for arbitrary checkpoints — vLLM's
`/v1/messages` endpoint, which requires a release with thinking-block
support in the Anthropic endpoint (post-2026-02-28; verified against
v0.22.1rc1). The lane reuses `AnthropicProvider` in compat mode: same
wire translation as the real Anthropic lane, but every model resolves to
the `_ANTHROPIC_COMPAT_DEFAULT` capabilities (200K context, 64K output,
`token_param=max_tokens`, `thinking_mode=none`, no native
web_search/tool_search, no vision) — the static Claude table never
applies to local checkpoints. `base_url` is required — the server root
WITHOUT `/v1` (the Anthropic SDK appends `/v1/messages`); a trailing
`/v1` pasted out of openai-compatible habit is stripped automatically,
and an empty value fails at client construction rather than falling
back to the commercial endpoint. Set a
placeholder `api_key` (e.g. `"dummy"`) for unauthenticated servers. Tool calling
needs the server started with `--enable-auto-tool-choice
--tool-call-parser <family>` plus the matching reasoning parser.
Per-model capability overrides opt in to what the checkpoint actually
supports:
```toml
[models.vllm-claude]
provider = "anthropic-compatible"
base_url = "http://localhost:8000" # no /v1 — the SDK appends /v1/messages
api_key = "dummy"
model = "deepseek-ai/DeepSeek-V4-Flash"
[models.vllm-claude.capabilities]
supports_vision = true # multimodal checkpoints only
supports_mid_conversation_system = true # template-dependent
context_window = 131072
```
The reasoning toggle does NOT use Anthropic's `thinking` request param.
Toggle it through the chat template instead: set `{"chat_template_kwargs":
{"thinking": false}}` as extra body params in the admin Models
server-compat section (for this provider the section shows only the
extra-body field — server type, API surface, and thinking mode are
openai-compatible-only knobs); the provider forwards it via the SDK's
`extra_body`.
Verified quirks of vLLM's Anthropic endpoint:
* The `thinking` request param is silently dropped — use
`chat_template_kwargs` (above) to control reasoning.
* `stop_sequences` cut the raw stream wherever the text appears —
including inside thinking — and report `end_turn` with
`stop_sequence=None`. Turnstone does not send stop sequences from
this provider.
* No cache telemetry: `usage` carries input/output token counts only
(no `cache_creation_input_tokens` / `cache_read_input_tokens`).
* Images require a multimodal checkpoint — text-only models return a
500 on image blocks, so `supports_vision` stays opt-in per model.
* Mid-conversation `role: "system"` turns are template-dependent —
opt in per model via `supports_mid_conversation_system`.
**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
+12 -5
View File
@@ -237,14 +237,21 @@ Key properties:
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.
`mode="all"` waits for every polled child to reach a real
terminal state.
- **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).
- **Unresolvable ids** — ws_ids are validated up front (exactly
32 hex chars; copy them verbatim): a malformed id fails the call
immediately with did-you-mean suggestions and a roster of the
coord's children. An id the caller doesn't own, a missing row, or
a child hard-deleted mid-wait is reported as `state="not_found"`
and aborts the wait on the tick that observes it (top-level
`error` / `not_found` / `children` fields, `complete=false`) — the
LLM should fix the id and re-issue, not conclude the child died.
Foreign and missing collapse into one shape, so the wait can't be
used as an existence oracle.
Prefer `wait_for_workstream` over polling `inspect_workstream` in a
loop — a wait consumes one assistant turn regardless of how long the
+25 -11
View File
@@ -168,19 +168,33 @@ Every ws_id returned by `spawn_workstream` / `spawn_batch` is a
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:
`user_id=owner` in storage. The rejection shape is uniform and
recovery-oriented:
- **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".
`cancel_workstream`, `delete_workstream`) and
**`inspect_workstream`** return
`{"error": "no workstream matching '<ref>' among your children; …",
"status": 404, "ws_id": "<ref>", "did_you_mean": [...],
"children": [...], "children_truncated": bool}` — a did-you-mean
(edit distance ≤ 3 against the coord's own children, which catches
the garbled-hex incident class: a 32-char id whose `aaa` run
collapsed to `a`) plus a roster of the coord's children. A ref
that matches a child's display NAME is called out explicitly with
the right id (names are mutable labels, not addresses). Foreign
and nonexistent ids produce the same payload (no existence
oracle), every hint references only the coord's own children, and
near-miss ids are never auto-resolved — the skill should fix the
id and re-issue, not treat the child as dead.
- **`wait_for_workstream`** validates ids before waiting: a
malformed id fails the whole call immediately (`invalid_ws_ids`
carries the per-id payloads above, `elapsed=0`); a well-formed id
that is foreign, nonexistent, or hard-deleted mid-wait surfaces as
`state="not_found"` and aborts the wait on that tick with
top-level `error` / `not_found` / `children` fields.
`complete=true` therefore means every polled lane really finished
— an unobservable id can neither burn the timeout nor ride along
to a "complete" result.
Pattern: capture each spawn result in the next tool call's input.
The JSON tool-result carries `{"child_ws_id": "...", "name": "...",
+24 -1
View File
@@ -231,6 +231,23 @@ calls for approval, it calls `_evaluate_intent()` which:
4. Attaches each heuristic verdict to its item as `_heuristic_verdict`
5. The daemon thread runs the LLM judge and delivers results via `ui.on_intent_verdict()`
The daemon evaluates items sequentially, so a large parallel batch can outlive
its approval gate. With `cancel_on_approval = false` (the default) the daemon
runs every item to completion: verdicts that land after the operator decided
still stream to the UI and persist, stamped with the decision. The daemon is
aborted only when the next tool batch supersedes it or the session closes —
then each unfinished item degrades to an `llm_fallback` verdict. With
`cancel_on_approval = true` the abort additionally fires the moment the gate
resolves, trading verdict completeness for inference savings — recommended
when the judge shares a single local inference backend with the session model,
where a large batch's remaining judge calls would otherwise compete with the
next turn's completion.
Verdicts that arrive after a *newer batch* has replaced the judge generation
are withheld from the live surfaces (a reused call_id must never ride a stale
`approve` into Smart Approvals) but still persist with
`user_decision = "superseded"` so the audit trail records the judge's answer.
Sub-agents (plan agent, task agent) are exempt from intent validation -- they
always get full tool visibility without judge evaluation.
@@ -242,7 +259,13 @@ All verdicts are persisted to the `intent_verdicts` table (migration 012):
- Heuristic verdicts are stored when the `approve_request` event is emitted
- LLM verdicts are stored when the `intent_verdict` event is delivered
- The `user_decision` column is updated when the user approves or denies
- The `user_decision` column is updated when the user approves or denies;
auto-approved rows carry the bypass reason (`policy`, `blanket`,
`auto_approve_tools`, `smart_approval`), and rows whose verdict landed only
after a newer batch replaced the judge generation carry `superseded`
- Every stored verdict — including the benign `risk_level = "none"` majority —
is re-attached to its tool call on history replay, so a reloaded workstream
shows the same verdict badges the live stream did
The console admin panel exposes verdict history via:
+1 -1
View File
@@ -108,7 +108,7 @@ pgbouncer:
maxClientConn: 5000
maxDbConnections: 80
```
:
---
## Configuration reference
+18 -17
View File
@@ -6,10 +6,9 @@ Turnstone ships several parallel release tracks from a single PyPI package.
| Track | Versions | Branch | Docker tags | PyPI install |
|-------|----------|--------|-------------|--------------|
| **Legacy 1.0** | `1.0.x` | `stable/1.0` | `:1.0.x`, `:1.0` | `pip install 'turnstone==1.0.*'` |
| **Stable 1.3** | `1.3.x` | `stable/1.3` | `:1.3.x`, `:1.3` | `pip install 'turnstone==1.3.*'` |
| **Stable 1.4** | `1.4.x` | `stable/1.4` | `:1.4.x`, `:1.4`, `:stable`, `:latest` | `pip install turnstone` |
| **Experimental** | `1.5.0aN` | `main` | `:1.5.0aN`, `:experimental` | `pip install turnstone --pre` |
| **Stable 1.5** | `1.5.x` | `stable/1.5` | `:1.5.x`, `:1.5` | `pip install 'turnstone==1.5.*'` |
| **Stable 1.6** | `1.6.x` | `stable/1.6` | `:1.6.x`, `:1.6`, `:stable`, `:latest` | `pip install turnstone` |
| **Experimental** | `1.7.0aN` | `main` | `:1.7.0aN`, `:experimental` | `pip install turnstone --pre` |
- **Stable** tracks receive bugfixes only. The most-recent stable minor
owns the `:stable` / `:latest` Docker tags and the default PyPI
@@ -17,8 +16,10 @@ Turnstone ships several parallel release tracks from a single PyPI package.
- **Experimental** (always on `main`) receives new features. May be
rough around the edges.
- When experimental matures, it is promoted to a new stable minor via
a `stable/X.Y` branch; older stable branches continue to receive
security fixes until explicitly retired.
a `stable/X.Y` branch. One prior stable track is maintained alongside
the current one; at each promotion the oldest track is retired — its
branch is deleted, while its tags and released artifacts remain
available.
## Version Scheme
@@ -33,17 +34,17 @@ Turnstone ships several parallel release tracks from a single PyPI package.
## Releasing an Experimental Version (from main)
```bash
scripts/release.sh 1.5.0a2 --push
scripts/release.sh 1.7.0a2 --push
```
This bumps `pyproject.toml` + `turnstone/__init__.py`, regenerates `uv.lock`, commits, tags `v1.5.0a2`, and pushes. CI runs, then publish + Docker workflows fire automatically.
This bumps `pyproject.toml` + `turnstone/__init__.py`, regenerates `uv.lock`, commits, tags `v1.7.0a2`, and pushes. CI runs, then publish + Docker workflows fire automatically.
## Releasing a Stable Patch (from stable/X.Y)
```bash
git checkout stable/1.4
git checkout stable/1.6
git cherry-pick <commit-hash> # bugfix from main
scripts/release.sh 1.4.1 --push
scripts/release.sh 1.6.1 --push
```
## Promoting Experimental to Stable
@@ -52,19 +53,19 @@ When `main` is ready for a stable release:
```bash
# 1. Tag the stable release on main
scripts/release.sh 1.5.0 --push
scripts/release.sh 1.6.0 --push
# 2. Create the stable maintenance branch from that tag
git branch stable/1.5 v1.5.0
git push origin stable/1.5
git branch stable/1.6 v1.6.0
git push origin stable/1.6
# 3. Start the next experimental cycle on main
scripts/release.sh 1.6.0a1 --push
scripts/release.sh 1.7.0a1 --push
```
The previous stable branch (`stable/1.4`) continues to receive
security-only patches; older tracks (`stable/1.0`, `stable/1.3`) are
retired when they fall out of support.
The previous stable branch continues to receive security-only patches;
the track before it is retired at each promotion (at 1.6.0:
`stable/1.5` stays maintained, `stable/1.4` is retired).
## CI/CD Pipeline
+26
View File
@@ -88,6 +88,32 @@ Console (CA + ACME Server)
- **Frontend cert** (HTTPS): From an external ACME CA (e.g. Let's Encrypt)
if `tls.acme_directory` is set, otherwise self-issued from the internal CA.
### Boot, retry, and fallback
With `tls.enabled`, a node fetches the CA cert and requests its own cert
during startup, retrying with exponential backoff (6 attempts, ~31 s total)
— enough to absorb a whole-stack restart where every node races the console
for its listener. If all attempts fail, the node **falls back to plain
HTTP** (availability over confidentiality) and reports `"tls": "fallback"`
in `GET /health`; a node serving HTTPS reports `"tls": "active"`, and the
key is absent when TLS is disabled. Fallback persists until the next
restart — it is not upgraded in place.
### Container healthcheck under mTLS
An mTLS listener rejects plain-HTTP probes at the socket, so
`docker/healthcheck.py` falls back to HTTPS when the plain probe fails:
it presents the node's own cert as the client cert and pins the cluster
CA, using the PEM files the server writes at boot under
`$TURNSTONE_TLS_PEM_DIR` (default `<tmpdir>/turnstone-tls`). The probe
dials `localhost` for the TLS attempt — the internal CA issues DNS SANs
only, so a literal-IP URL would fail verification. Cert renewal rewrites
the PEM dir alongside the live listener swap, so the probe's client cert
never outlives the served cert. With TLS disabled the plain probe succeeds
and the PEM directory is never consulted. On bare metal with multiple
nodes per host, set `TURNSTONE_TLS_PEM_DIR` per node (each boot clears
stale `lacme-pem-*` dirs under its root).
---
## Configuration
+1 -1
View File
@@ -7,7 +7,7 @@ name = "mcp-cluster-ops"
version = "0.1.0"
description = "MCP server for Turnstone cluster operations — reference implementation."
requires-python = ">=3.11"
license = "BUSL-1.1"
license = "Apache-2.0"
dependencies = [
"turnstone",
"mcp>=1.6",
+13 -3
View File
@@ -4,10 +4,11 @@ build-backend = "hatchling.build"
[project]
name = "turnstone"
version = "1.6.0rc1"
version = "1.6.2"
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
readme = "README.md"
license = "BUSL-1.1"
license = "Apache-2.0"
license-files = ["LICENSE", "NOTICE", "THIRD-PARTY-NOTICES"]
requires-python = ">=3.11"
authors = [{name = "Patrick Buckley", email = "buckleypm@gmail.com"}]
keywords = ["ai", "chat", "llm", "agent", "tools", "openai"]
@@ -25,7 +26,7 @@ dependencies = [
"openai>=2.37",
"anthropic>=0.108", # claude-fable-5 support; hard runtime floor is 0.105 (mid-conversation system blocks)
"httpx>=0.28",
"mcp>=1.27",
"mcp>=1.27,<2", # v2 is a breaking rewrite (2.0.0a1 live 2026-06-11; stable ~2026-07-27) — streamablehttp_client removed, 2-tuple transport, snake_case types; migrate deliberately
"starlette>=1.0.1", # PYSEC-2026-161: host-header path-injection in URL reconstruction (auth-bypass on apps comparing reconstructed URL paths)
"uvicorn>=0.34",
"sse-starlette>=2.0",
@@ -93,6 +94,15 @@ include = [
[tool.pytest.ini_options]
testpaths = ["tests"]
markers = ["live: requires a running LLM backend"]
filterwarnings = [
# mcp v1 deprecates streamablehttp_client for an entry point whose call
# shape only settles in v2 — adoption rides the deliberate v2 migration
# (pin capped <2); silence exactly this message until then.
"ignore:Use `streamable_http_client` instead",
# starlette deprecates the httpx-backed TestClient; revisit at the next
# starlette floor bump.
"ignore:Using `httpx` with `starlette.testclient` is deprecated",
]
[tool.ruff]
target-version = "py311"
+503
View File
@@ -0,0 +1,503 @@
#!/usr/bin/env python3
"""Build the livepass harnesses — render real hatch dialogs/shelves headlessly.
The livepass is how converted modal surfaces get verified without booting a
server: a minimal page that symlinks the REAL stylesheets and scripts, embeds
the REAL markup (extracted fresh from the index files at build time), stubs
``window.authFetch`` with canned fixtures, and drives surfaces via ``?open=``
query params including click-driving submits so dead buttons can't hide
(the model-Save bug class).
Usage:
python3 scripts/livepass.py # build into /tmp/livepass/
python3 scripts/livepass.py --out DIR # build elsewhere
python3 scripts/livepass.py --serve 8950 # build + serve (Ctrl+C stops)
Then screenshot states (file:// blocks ES modules always serve over http;
the reduced-motion flag is REQUIRED, entrance animations race the capture):
google-chrome --headless --disable-gpu --hide-scrollbars \\
--force-prefers-reduced-motion --window-size=1440,900 \\
--virtual-time-budget=9000 --screenshot=out.png \\
"http://localhost:8950/ui/livepass.html?open=new-ws&theme=light"
UI harness (?open=): new-ws · new-ws-fork · edit-title · delete-ws ·
revoke-mcp · ws-delete · ws-delete-results (+ &theme=light, &busy=1)
Console harness (?open=): schedule-create · schedule-edit · model-create ·
model-edit · model-save (drives a Save click; document.title becomes
PUT-OK-<n> on success) · policy · confirm · token
Plus &tall=1 (90-row users panel the .admin-content scroll state; the
synthetic rows wrap to two lines, so judge overflow geometry, not row
cadence) · &scrolled=1 lands mid-list, &scrolled=bottom shows the 24px
scroll tail · &focuslast=1 focuses the last shelf-body control (the
displaced-dock regression probe: only .sh-body may scroll; head/foot stay
pinned). All combinable with ?open=. The console page wraps the fragment
in the REAL L-shell chain pane-pinned height, interior scroller so
scroll/dock geometry matches production; keep it that way. Body-level
dialogs (confirm/install/coord-delete) are injected as riders; a driven
?open= that ends with no open dialog stamps OPEN-FAILED-<state> into the
title instead of passing silently.
Governance surfaces (roles/HR/OGP/memory/skill) need fixtures that are not
canned yet add a fixture + driver branch below when you need one.
Rebuild after ANY markup change: the dialog blocks are embedded at build
time. Assets are symlinked, so CSS/JS edits are live on refresh.
"""
from __future__ import annotations
import argparse
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
UI_INDEX = ROOT / "turnstone/ui/static/index.html"
CONSOLE_INDEX = ROOT / "turnstone/console/static/index.html"
def extract_dialogs(index: Path, only_id: str | None = None) -> list[str]:
"""Every <dialog class="hatch ..."> block, verbatim from the tree."""
html = index.read_text(encoding="utf-8")
blocks = []
for m in re.finditer(r"[ \t]*<dialog\s[^>]*class=\"[^\"]*\bhatch\b[^\"]*\"", html):
end = html.index("</dialog>", m.start()) + len("</dialog>")
block = html[m.start() : end]
if only_id and f'id="{only_id}"' not in block:
continue
blocks.append(block)
if not blocks:
raise SystemExit(f"no dialog.hatch blocks found in {index}")
return blocks
def extract_admin_fragment() -> str:
"""The console admin pane — the hatch-host all shelves live inside."""
html = CONSOLE_INDEX.read_text(encoding="utf-8")
start = html.index('<div id="admin-layout"')
end = html.index("<!-- /admin-layout -->") + len("<!-- /admin-layout -->")
return html[start:end]
def inject(template: str, marker: str, payload: str) -> str:
begin = template.index(f"<!-- {marker}:BEGIN -->") + len(f"<!-- {marker}:BEGIN -->")
end = template.index(f"<!-- {marker}:END -->")
return template[:begin] + "\n" + payload + "\n" + template[end:]
def symlink(link: Path, target: Path) -> None:
if link.is_symlink() or link.exists():
link.unlink()
link.symlink_to(target)
# --------------------------------------------------------------------------
# UI harness — the standalone app's dialog tier. Drives the REAL cards.js
# controller for the batch surfaces so the production code path renders.
# --------------------------------------------------------------------------
UI_TEMPLATE = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>ui livepass</title>
<link rel="stylesheet" href="shared/base.css" />
<link rel="stylesheet" href="shared/ui-base.css" />
<link rel="stylesheet" href="shared/chat.css" />
<link rel="stylesheet" href="shared/conversation.css" />
<link rel="stylesheet" href="shared/cards.css" />
<link rel="stylesheet" href="static/style.css" />
<link rel="stylesheet" href="shared/shell.css" />
<link rel="stylesheet" href="shared/interactive.css" />
<link rel="stylesheet" href="shared/hatch.css" />
</head>
<body>
<!-- DIALOGS:BEGIN -->
<!-- DIALOGS:END -->
<div id="toast" role="status" aria-live="polite"></div>
<script>
window.authFetch = function (url) {
// One canned failure so the results view shows the mixed state.
var fail = url && url.indexOf("c3d4e5f6a1b2") !== -1;
return Promise.resolve({
ok: !fail,
status: fail ? 409 : 200,
headers: { get: function () { return "application/json"; } },
json: function () { return Promise.resolve({}); },
text: function () {
return Promise.resolve(
fail ? '{"error": "workstream is still running"}' : "",
);
},
});
};
window.showToast = function (msg) { console.log("toast:", msg); };
</script>
<script type="module">
import { openDialog, setBusy } from "./shared/hatch.js";
const q = new URLSearchParams(location.search);
if (q.get("theme") === "light")
document.documentElement.dataset.theme = "light";
const open = q.get("open") || "";
function fill(id, text) {
const el = document.getElementById(id);
if (el) el.textContent = text;
}
if (open === "new-ws" || open === "new-ws-fork") {
const dlg = document.getElementById("new-ws-dialog");
const canned = {
"new-ws-model": ["sonnet-4-6", "gpt-5-2", "qwen3-32b"],
"new-ws-judge-model": ["sonnet-4-6", "qwen3-32b"],
"new-ws-skill": ["code-review (default)", "deep-research"],
};
for (const id in canned) {
const s = document.getElementById(id);
for (const n of canned[id]) {
const o = document.createElement("option");
o.value = n;
o.textContent = n;
s.appendChild(o);
}
}
if (open === "new-ws-fork") {
fill("new-ws-title", "Fork workstream");
fill("new-ws-tag", "WS-FORK");
document.getElementById("new-ws-submit").textContent = "Fork";
const skillLabel = document.querySelector('label[for="new-ws-skill"]');
if (skillLabel) skillLabel.hidden = true;
document.getElementById("new-ws-skill").hidden = true;
document.getElementById("new-ws-attach-row").hidden = true;
}
openDialog(dlg);
} else if (open === "edit-title") {
document.getElementById("edit-title-input").value =
"lshell renovation pass 3";
openDialog(document.getElementById("edit-title-dialog"));
} else if (open === "delete-ws") {
fill(
"delete-ws-message",
'Delete "lshell renovation pass 3"? This cannot be undone.',
);
openDialog(document.getElementById("delete-ws-dialog"));
} else if (open === "revoke-mcp") {
fill(
"revoke-mcp-message",
"Revoke the connection to github? Tools that need this server will require re-consent.",
);
openDialog(document.getElementById("revoke-mcp-dialog"));
} else if (open === "ws-delete" || open === "ws-delete-results") {
// Drive the REAL shared controller so the dialog renders through
// the production code path (cards.js confirmSelection/confirm).
const mod = await import("./shared/cards.js");
const c = mod.createSavedCardsController({
idPrefix: "ws-delete",
buttonId: "ws-delete-btn",
noun: "workstream",
activateLabel: (s) => "Resume: " + (s.title || s.ws_id),
render: () => {},
buildDeleteRequest: (wsId) => ({
url: "/v1/api/workstreams/" + encodeURIComponent(wsId) + "/delete",
options: { method: "POST" },
}),
});
c.setItems([
{ ws_id: "a1b2c3d4e5f6", title: "lshell renovation pass 3" },
{ ws_id: "b2c3d4e5f6a1", title: "canonical trajectory spike" },
{
ws_id: "c3d4e5f6a1b2",
title:
"a very long workstream title that should wrap " +
"rather than punch out of the dialog box entirely",
},
]);
c.toggleAll();
c.confirmSelection();
if (open === "ws-delete-results") c.confirm();
}
if (q.get("busy")) {
const d = document.querySelector("dialog[open]");
if (d) setBusy(d, true);
}
</script>
</body>
</html>
"""
# --------------------------------------------------------------------------
# Console harness — the admin pane fragment hosts the shelves (token-created
# included); dialog-tier markup outside the fragment (confirm/install/
# coord-delete) is injected via the RIDERS marker in build().
# model-save click-drives the submit: document.title flips to PUT-OK-<n>.
# --------------------------------------------------------------------------
CONSOLE_TEMPLATE = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>console livepass</title>
<link rel="stylesheet" href="shared/base.css" />
<link rel="stylesheet" href="shared/ui-base.css" />
<link rel="stylesheet" href="console-static/style.css" />
<link rel="stylesheet" href="shared/shell.css" />
<link rel="stylesheet" href="shared/hatch.css" />
</head>
<body>
<!-- The REAL L-shell chain (shell.js buildShell + pane.js DOM, verbatim
class names) so the harness inherits production scroll geometry:
.pane-body > #view-admin > .admin-layout height-pin the hatch-host
and .admin-content is the pane's interior scroller. Never replace
this with bespoke height overrides the clipped-pane / displaced-
shelf regressions were invisible to the harness precisely because
it used to pin #admin-layout with its own CSS. -->
<div class="app">
<aside class="rail" id="shell-rail">
<div class="rail-brand">
<button class="brand-home" type="button">
<div class="brand-mark"></div>
<span class="brand-name">turnstone</span>
<span class="brand-sub">console</span>
</button>
</div>
</aside>
<main class="content">
<div class="tabbar"></div>
<div class="panes">
<section class="pane">
<!-- no .pane-head: PaneManager._mount builds section.pane >
div.pane-body only -->
<div class="pane-body">
<div id="view-admin">
<!-- FRAGMENT:BEGIN -->
<!-- FRAGMENT:END -->
</div>
</div>
</section>
</div>
</main>
</div>
<!-- Body-level dialog tier (confirm / install / coord-delete): their
markup sits OUTSIDE #admin-layout in index.html, so the fragment
extraction misses them build() injects every hatch dialog the
fragment does not already contain. -->
<!-- RIDERS:BEGIN -->
<!-- RIDERS:END -->
<div id="toast" role="status" aria-live="polite"></div>
<script>
(function () {
function reply(data) {
return Promise.resolve({
ok: true,
status: 200,
headers: { get: function () { return "application/json"; } },
json: function () { return Promise.resolve(data); },
text: function () { return Promise.resolve(JSON.stringify(data)); },
});
}
var SCHED = {
task_id: "t1", name: "nightly-digest", description: "Morning digest",
schedule_type: "cron", cron_expr: "0 6 * * 1,3,5", at_time: "",
target_mode: "auto", model: "fable-5", skill: "daily-digest",
initial_message: "Summarize overnight cluster activity.",
auto_approve: false, enabled: true,
notify_targets: [{ channel_type: "discord", channel_id: "8675309" }],
next_run: "2026-06-10T06:00:00",
};
var MODEL = {
definition_id: "def1", alias: "fable-5", model: "claude-fable-5",
provider: "anthropic", base_url: "", context_window: 200000,
capabilities: JSON.stringify({ supports_vision: true }),
enabled: true, temperature: null, max_tokens: null,
reasoning_effort: null, surface_persisted_reasoning: true,
replay_reasoning_to_model: false,
};
window.__putCount = 0;
window.authFetch = function (url, opts) {
var method = (opts && opts.method) || "GET";
if (method === "PUT" && url.indexOf("/model-definitions/def1") >= 0) {
window.__putCount++;
document.title = "PUT-OK-" + window.__putCount;
return reply({ ok: true });
}
if (url.indexOf("/schedules/preview") >= 0)
return reply({
valid: true, error: "",
next: [
"2026-06-10T06:00:00+00:00",
"2026-06-12T06:00:00+00:00",
"2026-06-15T06:00:00+00:00",
],
});
if (url.indexOf("/schedules/t1") >= 0) return reply(SCHED);
if (url.indexOf("/schedules") >= 0) return reply({ schedules: [SCHED] });
if (url.indexOf("/model-capabilities/known") >= 0)
return reply({ models: ["claude-fable-5", "claude-opus-4-8"] });
if (url.indexOf("/model-capabilities?") >= 0)
return reply({
known: true,
capabilities: {
context_window: 200000, supports_tools: true,
supports_streaming: true, supports_vision: true,
supports_web_search: true, supports_temperature: true,
supports_effort: true,
},
});
if (url.indexOf("/model-definitions/def1") >= 0) return reply(MODEL);
if (url.indexOf("/model-definitions") >= 0) return reply({ models: [] });
if (url.indexOf("/api/models") >= 0)
return reply({ models: [
{ alias: "fable-5", model: "claude-fable-5" },
{ alias: "gpt-5.2", model: "gpt-5.2" },
] });
if (url.indexOf("/skills") >= 0)
return reply({ skills: [{ name: "daily-digest" }, { name: "ops-runbook" }] });
if (url.indexOf("/policies") >= 0)
return reply({ policies: [
{ policy_id: "p1", name: "deny-rm", tool_pattern: "bash*rm*",
action: "deny", priority: 900, enabled: true },
{ policy_id: "p2", name: "default-ask", tool_pattern: "*",
action: "ask", priority: 0, enabled: true },
] });
return reply({});
};
window.showToast = function (m) {
console.log("toast:", m);
var t = document.getElementById("toast");
t.textContent = m;
t.classList.add("show");
};
})();
</script>
<script type="module" src="shared/utils.js"></script>
<script type="module" src="shared/hatch.js"></script>
<script src="console-static/admin.js"></script>
<script src="console-static/governance.js"></script>
<script>
window.addEventListener("load", function () {
var q = new URLSearchParams(location.search);
if (q.get("theme") === "light")
document.documentElement.dataset.theme = "light";
var open = q.get("open") || "";
// ?tall=1 the scroll state: one panel visible with enough rows to
// overflow the pane, so a screenshot shows .admin-content scrolling
// (and a shelf staying docked above it). Mirrors switchAdminTab's
// one-panel-visible invariant without booting the tab loaders.
if (q.get("tall")) {
var panels = document.querySelectorAll(".admin-panel");
for (var i = 0; i < panels.length; i++)
panels[i].style.display =
panels[i].id === "admin-users" ? "" : "none";
// No fallback: a fragment rename must fail loudly, not misplace rows.
var rowHost = document.querySelector("#admin-users [role=list]");
rowHost.textContent = ""; // drop the static "Loading users…" stub
for (var r = 0; r < 90; r++) {
var row = document.createElement("div");
row.className = "admin-row"; // real row chrome geometry tracks production
row.textContent =
"user-" + String(r).padStart(3, "0") + " \\u00b7 synthetic row";
rowHost.appendChild(row);
}
var content = document.getElementById("admin-content");
if (content && q.get("scrolled"))
content.scrollTop =
q.get("scrolled") === "bottom"
? content.scrollHeight // the 24px scroll-tail state
: content.scrollHeight / 2; // land mid-list
}
setTimeout(function () {
if (open === "schedule-create") showCreateScheduleModal();
else if (open === "schedule-edit") showEditScheduleModal("t1");
else if (open === "model-create") showCreateModelModal();
else if (open === "model-edit" || open === "model-save")
showEditModelModal("def1");
else if (open === "policy") {
window._govPolicies && _govPolicies.length === 0 &&
loadGovPolicies && loadGovPolicies();
showCreatePolicyModal();
} else if (open === "confirm")
showConfirmModal(
"Delete schedule",
"Delete nightly-digest? Its run history is removed with it. This cannot be undone.",
"Delete",
function () {},
);
else if (open === "token")
showTokenCreatedModal(
"tsk_9f2e41c7a8b35d60e1f4a2b89c7d3e5f6a1b0c9d8e7f6a5b4c3d2e1f0a9b8c7d",
);
if (open === "model-save")
setTimeout(function () {
document.getElementById("model-create-submit").click();
}, 900);
if (q.get("busy"))
setTimeout(function () {
var d = document.querySelector("dialog[open]");
if (d) window.TurnstoneHatch.setBusy(d, true);
}, 400);
// A driven state that ends with nothing open must fail LOUDLY in
// the screenshot pipeline, not render a quietly dialog-less page.
setTimeout(function () {
var top = document.querySelector("dialog[open]");
if (open && !top) document.title = "OPEN-FAILED-" + open;
// &focuslast=1 the displaced-dock regression probe: focus the
// last form control in the shelf BODY (the visually-hidden
// toggle/radio inputs live there). Only .sh-body may scroll;
// the head/foot strips must stay pinned in the screenshot.
if (top && q.get("focuslast")) {
var els = top.querySelectorAll(
".sh-body input, .sh-body select, .sh-body textarea",
);
if (els.length) els[els.length - 1].focus();
}
}, 600);
}, 150);
});
</script>
</body>
</html>
"""
def build(out: Path) -> None:
ui = out / "ui"
con = out / "console"
ui.mkdir(parents=True, exist_ok=True)
con.mkdir(parents=True, exist_ok=True)
symlink(ui / "shared", ROOT / "turnstone/shared_static")
symlink(ui / "static", ROOT / "turnstone/ui/static")
blocks = extract_dialogs(UI_INDEX)
# the coordinator batch dialog shares the cards.js builder — ride along
blocks += extract_dialogs(CONSOLE_INDEX, only_id="coord-delete-dialog")
(ui / "livepass.html").write_text(
inject(UI_TEMPLATE, "DIALOGS", "\n".join(blocks)), encoding="utf-8"
)
print(f"{ui}/livepass.html — {len(blocks)} dialogs")
symlink(con / "shared", ROOT / "turnstone/shared_static")
symlink(con / "console-static", ROOT / "turnstone/console/static")
frag = extract_admin_fragment()
# Dialog-tier markup living OUTSIDE #admin-layout (confirm, install,
# coord-delete) would otherwise be silently absent — and ?open=confirm
# would screenshot a dialog-less page while the gate stayed green.
riders = [b for b in extract_dialogs(CONSOLE_INDEX) if b not in frag]
page = inject(CONSOLE_TEMPLATE, "FRAGMENT", frag)
page = inject(page, "RIDERS", "\n".join(riders))
(con / "livepass.html").write_text(page, encoding="utf-8")
print(f"{con}/livepass.html — admin fragment + {len(riders)} rider dialogs")
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--out", type=Path, default=Path("/tmp/livepass"))
ap.add_argument("--serve", type=int, metavar="PORT")
args = ap.parse_args()
build(args.out)
if args.serve:
import functools
import http.server
handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=str(args.out))
print(f"serving {args.out} on http://localhost:{args.serve}/ — Ctrl+C stops")
http.server.ThreadingHTTPServer(("127.0.0.1", args.serve), handler).serve_forever()
if __name__ == "__main__":
main()
+1 -1
View File
@@ -7,7 +7,7 @@
"": {
"name": "@turnstone/sdk",
"version": "0.4.0",
"license": "BUSL-1.1",
"license": "Apache-2.0",
"devDependencies": {
"typescript": "^6.0.0",
"vitest": "^4.1"
+1 -1
View File
@@ -30,7 +30,7 @@
"sdk",
"client"
],
"license": "BUSL-1.1",
"license": "Apache-2.0",
"devDependencies": {
"typescript": "^6.0.0",
"vitest": "^4.1"
+104 -13
View File
@@ -285,6 +285,38 @@ def test_system_turn_dedups_against_history_by_event_id() -> None:
"replayHistory (and the live handler) must record system-turn ids for the dedup set."
)
# Pin the wiring on BOTH read paths, scoped to its method — a refactor that
# keeps the Set but drops the live-handler consultation (or the
# replayHistory-side record) silently re-opens the double-render while the
# file-global checks above still pass.
live_start = body.index('case "system_turn":')
# End at the NEXT switch case, not the first ``break;`` — the dedup-skip
# path breaks before the ``.add(``, so a ``break;``-bounded slice would
# drop the record half and false-fail the ``.add(`` assertion below.
# Whitespace-tolerant so a reformat can't silently break the bound.
next_case = re.search(r'\n\s*case "', body[live_start + 1 :])
assert next_case, (
"no switch case found after system_turn to bound the pin slice — if "
"system_turn became the last case, re-anchor this pin's end marker."
)
live_block = body[live_start : live_start + 1 + next_case.start()]
assert re.search(r"_renderedSystemEventIds[\s\S]*?\.\s*has\(", live_block), (
"the live system_turn handler must CONSULT the dedup set (skip an id "
"already painted from /history), not merely reference the Set elsewhere."
)
assert re.search(r"_renderedSystemEventIds[\s\S]*?\.\s*add\(", live_block), (
"the live system_turn handler must RECORD the id it renders so a later "
"/history re-render (clear_ui) doesn't repaint it."
)
replay_start = _pane_method_offset(body, "replayHistory")
replay_end = _pane_method_offset(body, "_attachRetryToLastAssistant")
replay_block = body[replay_start:replay_end]
assert re.search(r"_renderedSystemEventIds[\s\S]*?\.\s*add\(", replay_block), (
"replayHistory must record each replayed system row's event_id so the "
"live system_turn handler can dedup against it."
)
def test_retry_walk_skips_operator_context_cards() -> None:
"""Interactive twin of the coord retry-skip guard.
@@ -396,9 +428,11 @@ def test_phase8_mcp_error_helpers_defined() -> None:
(interactive consent / forbidden / operator card) moved into the shared
interactive module with the Pane. The consent-badge state
(``_pendingConsentServers`` / ``_onConsentDetected``) stays in the
standalone shell it drives the settings-gear badge and the pane reaches
it through the ``host.onConsentDetected`` seam (a no-op in the console,
which has no gear badge). Pin both halves and the seam."""
standalone shell it drives the rail's Manage-row badge — and the pane
reaches it through the ``host.onConsentDetected`` seam. The shared host
bridges that seam to the standalone via ``window.TS_APP.onConsentDetected``
(undefined on the console, so it stays a no-op there). Pin both halves and
the bridge."""
inter = _INTERACTIVE_JS.read_text(encoding="utf-8")
assert "function tryParseMcpError" in inter
assert "function buildMcpErrorEmbed" in inter
@@ -408,14 +442,62 @@ def test_phase8_mcp_error_helpers_defined() -> None:
assert "onConsentDetected(s)" in inter, (
"the pane must notify consent through host.onConsentDetected"
)
# The shared host bridges the seam to the standalone subsystem (feature-
# detected, so the console — which never defines the hook — no-ops).
assert "window.TS_APP.onConsentDetected(server)" in inter, (
"the shared interactive host must bridge onConsentDetected to the TS_APP seam"
)
app = _APP_JS.read_text(encoding="utf-8")
assert "_pendingConsentServers" in app
assert "function _onConsentDetected" in app
assert "onConsentDetected(server)" in app, (
"STANDALONE_HOST must wire host.onConsentDetected -> _onConsentDetected"
assert "window.TS_APP.onConsentDetected = _onConsentDetected" in app, (
"the standalone must expose _onConsentDetected on the TS_APP seam for the pane bridge"
)
def test_consent_badge_drives_rail_manage_row() -> None:
"""The pending-consent badge was re-homed off the retired settings gear
(``#settings-btn``, deleted in the L-shell renovation, which silently made
the badge invisible) onto the rail's Manage > Connections row. Classic
app.js can't import the ESM rail module, so it drives the rail's generic
``setRowBadge`` hook through the ``window.TS_SHELL`` bridge keyed on the
standalone's Connections tab. Pin the new lane and the absence of the dead
gear lookup."""
app = _APP_JS.read_text(encoding="utf-8")
# The badge refresh must drive the rail bridge, not the deleted gear.
assert 'getElementById("settings-btn")' not in app, (
"the consent badge must no longer target the retired #settings-btn gear"
)
assert "shell.setRowBadge(_CONSENT_BADGE_TAB" in app, (
"_refreshConsentBadge must drive the rail Manage-row badge via the TS_SHELL bridge"
)
assert 'const _CONSENT_BADGE_TAB = "connections"' in app, (
"the standalone badge rides the Connections Manage tab (its MCP surface)"
)
# The hydrate + clear paths must still funnel through the single refresh.
assert "function loadPendingConsents" in app and "_refreshConsentBadge()" in app
def test_media_player_activation_not_duplicated_in_standalone() -> None:
"""The media-player activation (``_loadHls`` / ``_activatePlayer`` + the
click/keydown delegate) moved into the shared interactive pane so BOTH the
standalone server and the console activate the Play button. The standalone
app.js must NOT keep its own copy a duplicate document-level listener
would double-fire on the standalone (two players swapped in) while the lift
is what fixed the console (where app.js was never the host). Pin the
standalone clean so the stale copy can't drift back in."""
app = _APP_JS.read_text(encoding="utf-8")
for name in ("_loadHls", "_activatePlayer", "_isHlsUrl", "media-play-btn"):
assert name not in app, (
f"standalone app.js must not re-declare the lifted media player "
f"({name!r}) — it lives in shared_static/interactive.js now"
)
# The lift target carries the real implementation (the click delegate too).
inter = _INTERACTIVE_JS.read_text(encoding="utf-8")
assert "function _activatePlayer(" in inter
assert "activateMediaPlayButton(btn)" in inter
def test_phase8_settings_panel_handlers_defined() -> None:
"""The settings modal exposes four entry points that the inline
``onclick`` attributes in index.html depend on. Renaming or
@@ -628,7 +710,8 @@ def test_dashboard_is_the_main_pane_body() -> None:
def test_mcp_connections_panel_and_revoke_modal_in_index_html() -> None:
"""MCP connections moved from the floating #settings-overlay into the Admin
pane's Connections panel (#view-admin), reusing the same #settings-mcp-*
table ids so the render code is unchanged. The revoke modal stays."""
table ids so the render code is unchanged. The revoke confirm lives on the
hatch dialog tier (native document-modal)."""
body = _INDEX_HTML.read_text(encoding="utf-8")
assert 'id="settings-overlay"' not in body, "the floating MCP settings overlay is retired."
assert 'id="view-admin"' in body, "the Admin pane host (#view-admin) must exist."
@@ -637,10 +720,11 @@ def test_mcp_connections_panel_and_revoke_modal_in_index_html() -> None:
assert 'id="settings-mcp-table"' in panel and 'id="settings-mcp-tbody"' in panel, (
"the MCP table (reused ids) must live inside #view-admin."
)
idx = body.index('id="revoke-mcp-overlay"')
chunk = body[idx : idx + 600]
assert 'role="dialog"' in chunk and 'aria-modal="true"' in chunk, (
"revoke-mcp-overlay stays a modal dialog."
idx = body.index('id="revoke-mcp-dialog"')
chunk = body[max(0, idx - 200) : idx + 600]
assert "hatch--dialog" in chunk and 'role="alertdialog"' in chunk, (
"the revoke confirm is a hatch dialog-tier alertdialog "
"(native showModal supplies modality — no aria-modal attribute)."
)
@@ -672,7 +756,11 @@ def test_phase8_css_classes_present_in_stylesheet() -> None:
must keep their CSS rules (else the consent / connections UX silently loses
its visual treatment). The settings OVERLAY is retired in step 6 MCP
connections render in the Admin pane's Connections panel (#view-admin), not a
floating dialog so #settings-overlay / #settings-box are no longer pinned."""
floating dialog so #settings-overlay / #settings-box are no longer pinned.
The revoke confirm's chrome moved to /shared/hatch.css with the dialog-tier
conversion, so no #revoke-mcp-* rule is pinned here either. The pending-
consent badge moved off the retired settings gear onto the rail's Manage row
(shell.css `.rail-badge`), so `.settings-consent-badge` is gone from here."""
css = _STYLE_CSS.read_text(encoding="utf-8")
for selector in [
".mcp-error-card",
@@ -680,10 +768,13 @@ def test_phase8_css_classes_present_in_stylesheet() -> None:
".mcp-error-action-btn",
".mcp-scope-pill",
".settings-revoke-btn",
".settings-consent-badge",
"#revoke-mcp-overlay",
]:
assert selector in css, f"Missing CSS rule for {selector}"
# The dead gear-badge rule must be GONE (its host #settings-btn was retired).
assert ".settings-consent-badge" not in css, (
"the retired settings-gear consent badge CSS must be removed "
"(the badge now lives on the rail Manage row — shell.css .rail-badge)"
)
def test_phase8_consent_url_prefix_check_in_click_handler() -> None:
+332 -75
View File
@@ -9,6 +9,7 @@ storage-call path.
from __future__ import annotations
import json
import threading
import time
from typing import TYPE_CHECKING, Any
@@ -405,7 +406,10 @@ def test_mutating_ops_reject_foreign_ws_id_without_hitting_proxy():
]:
result = call("ws-foreign", **kwargs) # type: ignore[arg-type]
assert result["status"] == 404
assert "not in coordinator subtree" in result["error"]
assert "no workstream matching" in result["error"]
# Recovery payload: a roster of the coord's own children rides
# along so a garbled id is fixable in one round-trip.
assert {c["ws_id"] for c in result["children"]} == {"ws-x", "ws-y"}
# No HTTP requests issued — guard rejected before _post.
assert captured == []
@@ -421,6 +425,56 @@ def test_mutating_ops_accept_self_ws_id():
assert captured[0].url.path == "/v1/api/route/workstreams/coord-1/send"
def test_mutating_ops_reject_foreign_hex_id_with_recovery_payload():
"""A well-formed 32-hex id that isn't ours passes format validation
and dies on the ownership guard with the SAME recovery payload as a
malformed ref uniform shape, no existence oracle, no HTTP."""
client, captured = _mock_client(_ok_json({"status": 200}))
result = client.send("f" * 32, "hi")
assert result["status"] == 404
assert "no workstream matching" in result["error"]
assert {c["ws_id"] for c in result["children"]} == {"ws-x", "ws-y"}
assert captured == []
def test_mutating_ops_reject_child_name_with_id_pointer(tmp_path):
"""A model that pastes a child's display NAME instead of its id is
pointed straight at the right ws_id names are mutable, non-unique
labels (the title generator can rewrite what the operator sees), so
they are deliberately NOT addresses and nothing resolves silently."""
st = SQLiteBackend(str(tmp_path / "names.db"))
st.register_workstream("coord-1", kind="coordinator", user_id="user-1")
real = "7c61eafe470c54caaa89490a4b9c0f7d"
st.register_workstream(
real,
kind="interactive",
parent_ws_id="coord-1",
state="running",
user_id="user-1",
name="minisforum-research",
)
captured: list[httpx.Request] = []
def _trap(req: httpx.Request) -> httpx.Response:
captured.append(req)
return httpx.Response(200, json={})
client = CoordinatorClient(
console_base_url="http://console",
storage=st,
token_factory=lambda: "t",
coord_ws_id="coord-1",
user_id="user-1",
http_client=httpx.Client(transport=httpx.MockTransport(_trap)),
child_event_bus=ChildEventBus(),
)
result = client.send("minisforum-research", "status?")
assert result["status"] == 404
assert "names are display labels" in result["error"]
assert result["did_you_mean"][0]["ws_id"] == real
assert captured == []
# ---------------------------------------------------------------------------
# Read ops — storage-backed
# ---------------------------------------------------------------------------
@@ -558,34 +612,42 @@ def test_inspect_missing_ws_returns_error(populated_storage):
assert "error" in result
def test_inspect_not_found_does_not_echo_ws_id_in_error_string(populated_storage):
"""The error STRING is bare ("workstream not found") — the
structured ``ws_id`` field carries the queried id. Pre-fix the
error message echoed the ws_id back at the caller who just sent
it, which was redundant and a stylistic departure from the rest
of the surface. Echo-in-string is also one more place a
hostile/oversize ws_id could land in operator-facing text."""
def test_inspect_not_found_references_ref_but_clips_oversize(populated_storage):
"""The error string names the unresolvable ref — it sits next to
the did-you-mean hints now, so it's load-bearing context — but
clips it to a bounded length so a hostile / oversize ws_id can't
flood operator-facing text (the prior bare-string design's
concern). The structured ``ws_id`` field carries the full
value, and the format note reports the true length."""
client = _make_read_client(populated_storage)
result = client.inspect("does-not-exist-xyz")
assert result["error"] == "workstream not found"
# The structured field still carries the ws_id for context.
assert "does-not-exist-xyz" in result["error"]
assert result["ws_id"] == "does-not-exist-xyz"
oversize = "z" * 300
clipped = client.inspect(oversize)
assert oversize not in clipped["error"]
assert "(got 300)" in clipped["error"]
assert clipped["ws_id"] == oversize
def test_inspect_cross_tenant_returns_same_shape_as_missing(populated_storage):
"""The cross-tenant guard MUST return the exact same shape as a
genuinely missing ws_id that's the existence-leak defence the
error-string echo was carrying weight for too. Asserting the
shape match here pins the property going forward."""
# ``unrelated`` exists in storage but is not a coord-1 child.
genuinely missing ws_id the existence-leak defence. The error
text embeds the (caller-supplied) ref, so compare with the refs
factored out; same-length refs make the strings otherwise
byte-identical."""
# ``unrelated`` exists in storage but is not a coord-1 child;
# ``missing-x`` (same length) doesn't exist at all.
client = _make_read_client(populated_storage)
cross_tenant = client.inspect("unrelated")
missing = client.inspect("does-not-exist-abc")
# Same key set, same error string, only the ws_id field differs.
missing = client.inspect("missing-x")
assert cross_tenant.keys() == missing.keys()
assert cross_tenant["error"] == missing["error"] == "workstream not found"
assert "no workstream matching" in missing["error"]
assert cross_tenant["error"].replace("unrelated", "X") == missing["error"].replace(
"missing-x", "X"
)
assert cross_tenant["ws_id"] == "unrelated"
assert missing["ws_id"] == "does-not-exist-abc"
assert missing["ws_id"] == "missing-x"
def test_list_children_excludes_closed_by_default(tmp_path):
@@ -1252,76 +1314,266 @@ def test_wait_for_workstream_all_mode_times_out_on_running_child(populated_stora
assert result["results"]["child-b"]["state"] == "running"
def test_wait_for_workstream_denies_foreign_ws_id(populated_storage):
"""A ws_id outside the coordinator's subtree returns state='denied'.
With mode='any' on a pure-denied list there's no real work to wait
for, so the wait short-circuits sub-second with complete=False
the model sees the denied state immediately and can correct rather
than spinning the timeout."""
def test_wait_for_workstream_foreign_legacy_ref_fails_validation(populated_storage):
"""A ref outside the coordinator's subtree that isn't id-shaped
('unrelated') dies at the validation boundary: the call errors
immediately with a per-ref recovery payload and performs no
waiting at all."""
client = _make_read_client(populated_storage)
result = client.wait_for_workstream(["unrelated"], timeout=5, mode="any")
assert result["results"]["unrelated"]["state"] == "denied"
assert result["complete"] is False
assert result["elapsed"] < 1.0
assert result["elapsed"] == 0.0
assert result["results"] == {}
assert "no workstream matching" in result["error"]
assert result["invalid_ws_ids"][0]["ws_id"] == "unrelated"
# Unified channel shape: trimmed per-ref entries, roster once at
# top level (same as the in-loop not_found channel).
assert "children" not in result["invalid_ws_ids"][0]
assert {c["ws_id"] for c in result["children"]} == {"child-a", "child-b", "child-coord"}
def test_wait_for_workstream_denies_cross_tenant_child(populated_storage):
def test_wait_for_workstream_cross_tenant_child_fails_validation(populated_storage):
"""Defense-in-depth (Copilot #506): a row whose ``parent_ws_id``
matches the coordinator but whose ``user_id`` belongs to a
different tenant must collapse to ``denied`` otherwise a
forged / migration-era / pre-tenant-gate row would let a
coordinator's LLM observe foreign-tenant state through
different tenant must stay unobservable. The validation roster is
tenant-filtered in SQL, so the forged row never resolves and the
coordinator's LLM can't observe foreign-tenant state through
``wait_for_workstream``. The ``populated_storage`` fixture's
``cross-tenant-child`` row has exactly this shape
(parent_ws_id="coord-1", user_id="user-2").
"""
(parent_ws_id="coord-1", user_id="user-2")."""
client = _make_read_client(populated_storage)
result = client.wait_for_workstream(["cross-tenant-child"], timeout=5, mode="any")
assert result["results"]["cross-tenant-child"]["state"] == "denied"
assert result["complete"] is False
assert result["elapsed"] < 1.0
assert result["results"] == {}
assert "no workstream matching" in result["error"]
def test_wait_for_workstream_missing_ws_id_indistinguishable_from_denied(populated_storage):
"""A ws_id that doesn't exist collapses into the same 'denied'
shape as a foreign ws_id so wait can't be used as an existence
oracle (matches the 404-mask contract inspect uses). Same
short-circuit semantics as the pure-foreign case."""
def test_wait_for_workstream_missing_ref_indistinguishable_from_foreign(populated_storage):
"""A ref that doesn't exist produces the same payload as a foreign
one (same-length refs make the error strings byte-identical once
the echoed ref is factored out), so the validation boundary can't
be used as an existence oracle."""
client = _make_read_client(populated_storage)
result = client.wait_for_workstream(["does-not-exist"], timeout=5, mode="any")
assert result["results"]["does-not-exist"]["state"] == "denied"
foreign = client.wait_for_workstream(["unrelated"], timeout=5)
missing = client.wait_for_workstream(["missing-x"], timeout=5)
f_err, m_err = foreign["invalid_ws_ids"][0], missing["invalid_ws_ids"][0]
assert f_err.keys() == m_err.keys()
assert f_err["error"].replace("unrelated", "X") == m_err["error"].replace("missing-x", "X")
def test_wait_for_workstream_mixed_invalid_ref_errors_whole_call(populated_storage):
"""Successor to the bug-2 false-positive regression: one valid
(running) child plus one unresolvable ref must never produce a
'complete' wait. Under the fail-fast contract the whole call
errors immediately a partial wait over the valid subset would
hide exactly the lost-lane failure the validation exists to
surface."""
client = _make_read_client(populated_storage)
result = client.wait_for_workstream(["child-b", "unrelated"], timeout=5, mode="any")
assert result["complete"] is False
assert result["elapsed"] < 1.0
assert result["elapsed"] == 0.0
assert result["results"] == {}
assert result["invalid_ws_ids"][0]["ws_id"] == "unrelated"
# mode='all' is identical — previously a denied member counted as
# 'settled' and the wait completed, silently dropping the lane.
result_all = client.wait_for_workstream(["child-a", "unrelated"], timeout=5, mode="all")
assert result_all["complete"] is False
assert result_all["results"] == {}
def test_wait_for_workstream_any_does_not_short_circuit_on_mixed_denied(populated_storage):
"""Regression for the bug-2 false-positive: mode='any' with one
real (running) child and one denied id must NOT return
complete=True on the denied id wait until the real child reaches
a real terminal state, or time out."""
client = _make_read_client(populated_storage)
result = client.wait_for_workstream(["child-b", "unrelated"], timeout=1.0, mode="any")
# child-b never reaches terminal in the test fixture; denied alone
# must not satisfy the any condition; wait must hit the timeout.
# ---------------------------------------------------------------------------
# wait_for_workstream — in-loop not_found fail-fast (32-hex refs)
# ---------------------------------------------------------------------------
#
# Production ws_ids are ``uuid4().hex``. A well-formed-but-unobservable
# id passes the validation boundary and must abort the wait on the first
# tick that sees it — never burn the timeout, never ride along to a
# "complete" result. The fixture mirrors the original field incident: a
# coordinator LLM collapsed the ``aaa`` run in a child's id to a single
# ``a`` and then read the resulting not-found as a dead child.
REAL_CHILD_HEX = "7c61eafe470c54caaa89490a4b9c0f7d"
CORRUPTED_CHILD_HEX = "7c61eafe470c54ca89490a4b9c0f7d" # aaa -> a, 30 chars
RUNNING_CHILD_HEX = "9cc8205058d528130fb469eaf75650f3"
FOREIGN_HEX = "f" * 32
MISSING_HEX = "e" * 32
FORGED_HEX = "d" * 32 # parent_ws_id forged to coord-1, foreign user_id
@pytest.fixture
def hex_storage(tmp_path):
st = SQLiteBackend(str(tmp_path / "coord-hex.db"))
st.register_workstream("coord-1", kind="coordinator", user_id="user-1")
st.register_workstream(
REAL_CHILD_HEX,
kind="interactive",
parent_ws_id="coord-1",
state="idle",
user_id="user-1",
name="minisforum-research",
)
st.register_workstream(
RUNNING_CHILD_HEX,
kind="interactive",
parent_ws_id="coord-1",
state="running",
user_id="user-1",
name="beelink-research",
)
st.register_workstream(FOREIGN_HEX, kind="interactive", user_id="user-2")
st.register_workstream(FORGED_HEX, kind="interactive", parent_ws_id="coord-1", user_id="user-2")
return st
def test_wait_incident_regression_corrupted_id_gets_did_you_mean(hex_storage):
"""THE incident: a 30-char id (character-run collapse) must fail the
call instantly with the real child id as a did-you-mean pre-fix
it burned the full timeout and read as a dead child."""
client = _make_read_client(hex_storage)
result = client.wait_for_workstream([CORRUPTED_CHILD_HEX], timeout=300, mode="all")
assert result["complete"] is False
assert result["elapsed"] >= 1.0
assert result["results"]["unrelated"]["state"] == "denied"
assert result["results"]["child-b"]["state"] == "running"
assert result["elapsed"] == 0.0
assert result["results"] == {}
bad = result["invalid_ws_ids"][0]
assert bad["ws_id"] == CORRUPTED_CHILD_HEX
assert bad["did_you_mean"][0]["ws_id"] == REAL_CHILD_HEX
assert bad["did_you_mean"][0]["name"] == "minisforum-research"
assert "(got 30)" in bad["error"]
def test_wait_for_workstream_all_completes_when_real_terminal_and_denied_mixed(
populated_storage,
):
"""mode='all' should consider denied ids as 'settled' so a wait on
[real-idle, denied] completes after the first tick instead of
waiting out the timeout the model gets the full results dict
and can act on the per-id state."""
client = _make_read_client(populated_storage)
result = client.wait_for_workstream(["child-a", "unrelated"], timeout=5, mode="all")
def test_wait_foreign_hex_id_aborts_on_first_tick(hex_storage):
"""A well-formed foreign id passes validation, snapshots as
``not_found``, and aborts the wait immediately even in mode='any'
with a real running child alongside (the old contract silently
waited out the timeout here)."""
client = _make_read_client(hex_storage)
result = client.wait_for_workstream([RUNNING_CHILD_HEX, FOREIGN_HEX], timeout=30, mode="any")
assert result["complete"] is False
assert result["elapsed"] < 5.0
assert result["results"][FOREIGN_HEX]["state"] == "not_found"
assert result["results"][FOREIGN_HEX]["message"] == (
"(no workstream with this id among your children)"
)
assert result["results"][RUNNING_CHILD_HEX]["state"] == "running"
assert [h["ws_id"] for h in result["not_found"]] == [FOREIGN_HEX]
assert "no workstream matching" in result["error"]
assert {c["ws_id"] for c in result["children"]} == {REAL_CHILD_HEX, RUNNING_CHILD_HEX}
def test_wait_mode_all_never_completes_with_not_found_member(hex_storage):
"""Successor to the silent-ride-along: mode='all' with [idle,
foreign] previously returned complete=True (denied counted as
'settled'), reporting success while a lane was missing. Now the
unobservable member aborts the call with complete=False."""
client = _make_read_client(hex_storage)
result = client.wait_for_workstream([REAL_CHILD_HEX, FOREIGN_HEX], timeout=5, mode="all")
assert result["complete"] is False
assert result["results"][FOREIGN_HEX]["state"] == "not_found"
assert result["results"][REAL_CHILD_HEX]["state"] == "idle"
def test_wait_foreign_and_missing_hex_payloads_identical(hex_storage):
"""Existence-oracle pin for the fail-fast path: an existing
foreign-tenant id and a nonexistent id produce identical result
entries and identical top-level hints (modulo the echoed ref)."""
client = _make_read_client(hex_storage)
foreign = client.wait_for_workstream([FOREIGN_HEX], timeout=5)
missing = client.wait_for_workstream([MISSING_HEX], timeout=5)
assert foreign["results"][FOREIGN_HEX] == missing["results"][MISSING_HEX]
f_hint, m_hint = foreign["not_found"][0], missing["not_found"][0]
assert f_hint.keys() == m_hint.keys()
assert f_hint["error"].replace(FOREIGN_HEX, "ID") == m_hint["error"].replace(MISSING_HEX, "ID")
def test_wait_results_carry_child_display_name(hex_storage):
"""Own-child entries carry the display ``name`` for orientation —
a label, not an address."""
client = _make_read_client(hex_storage)
result = client.wait_for_workstream([REAL_CHILD_HEX], timeout=5, mode="any")
assert result["complete"] is True
assert result["elapsed"] < 1.0
assert result["results"]["child-a"]["state"] == "idle"
assert result["results"]["unrelated"]["state"] == "denied"
assert result["results"][REAL_CHILD_HEX]["name"] == "minisforum-research"
def test_wait_mid_wait_hard_delete_aborts(hex_storage, monkeypatch):
"""A child hard-deleted while a wait is in flight flips to
``not_found`` on the next tick and aborts the wait the
coordinator hears about the vanished lane in seconds, not at
timeout."""
monkeypatch.setattr(CoordinatorClient, "_WAIT_HEARTBEAT_INTERVAL", 0.05)
client = _make_read_client(hex_storage)
def _delete_soon() -> None:
time.sleep(0.3)
hex_storage.delete_workstream(RUNNING_CHILD_HEX)
deleter = threading.Thread(target=_delete_soon)
deleter.start()
try:
result = client.wait_for_workstream([RUNNING_CHILD_HEX], timeout=30, mode="all")
finally:
deleter.join()
assert result["complete"] is False
assert result["results"][RUNNING_CHILD_HEX]["state"] == "not_found"
assert result["elapsed"] < 10.0
def test_inspect_corrupted_id_gets_did_you_mean(hex_storage):
"""inspect_workstream shares the validation boundary: the incident
id gets the did-you-mean pointer, and the real id still inspects."""
client = _make_read_client(hex_storage)
result = client.inspect(CORRUPTED_CHILD_HEX)
assert result["did_you_mean"][0]["ws_id"] == REAL_CHILD_HEX
assert "(got 30)" in result["error"]
ok = client.inspect(REAL_CHILD_HEX)
assert ok["state"] == "idle"
def test_inspect_rejects_forged_cross_tenant_hex_row(hex_storage):
"""Parity with the wait / mutating gates (#506): a row forged with
parent_ws_id=coord but a foreign user_id must not be readable
through inspect either same not-found shape, no history leak."""
client = _make_read_client(hex_storage)
result = client.inspect(FORGED_HEX)
assert "no workstream matching" in result["error"]
assert "messages" not in result
def test_ws_ref_validation_survives_roster_query_failure(hex_storage, monkeypatch):
"""Storage failure during the roster read degrades hints to empty
but validation still errors honestly (never resolves blind)."""
client = _make_read_client(hex_storage)
def _boom(*args: object, **kwargs: object) -> None:
raise RuntimeError("storage down")
monkeypatch.setattr(hex_storage, "list_workstreams", _boom)
result = client.send("not-a-real-id", "hi")
assert result["status"] == 404
assert "no workstream matching" in result["error"]
assert result["children"] == []
def test_uppercase_full_hex_ref_case_folds(hex_storage):
"""Models occasionally upcase hex; a full 32-hex ref resolves
case-insensitively."""
client = _make_read_client(hex_storage)
ok = client.inspect(REAL_CHILD_HEX.upper())
assert ok.get("error") is None
assert ok["state"] == "idle"
def test_wait_since_hint_does_not_mask_not_found(hex_storage):
"""The not_found fail-fast outranks the since-diff early exit — a
diffing since hint must not convert an unobservable-id abort into
complete=True."""
client = _make_read_client(hex_storage)
since = {RUNNING_CHILD_HEX: {"state": "idle", "tokens": 0, "updated": ""}}
result = client.wait_for_workstream(
[RUNNING_CHILD_HEX, FOREIGN_HEX], timeout=5, mode="any", since=since
)
assert result["complete"] is False
assert result["results"][FOREIGN_HEX]["state"] == "not_found"
def test_wait_for_workstream_rejects_invalid_mode(populated_storage):
@@ -1788,16 +2040,21 @@ def test_wait_for_workstream_closed_returns_sentinel(populated_storage):
assert snap["truncated"] is False
def test_wait_for_workstream_denied_returns_sentinel(populated_storage):
"""Cross-tenant / nonexistent ws_ids surface as denied — the
sentinel lets the coord LLM recognise the rejection without
parsing state strings on its own."""
client = _make_read_client(populated_storage)
result = client.wait_for_workstream(["unrelated"], timeout=5, mode="any")
snap = result["results"]["unrelated"]
assert snap["state"] == "denied"
assert snap["message"].startswith("(workstream denied")
def test_wait_for_workstream_not_found_returns_sentinel(hex_storage):
"""Unobservable ws_ids surface a fixed sentinel message so the
coord LLM recognises the rejection without parsing state strings
on its own."""
client = _make_read_client(hex_storage)
result = client.wait_for_workstream([FOREIGN_HEX], timeout=5, mode="any")
snap = result["results"][FOREIGN_HEX]
assert snap["state"] == "not_found"
assert snap["message"] == "(no workstream with this id among your children)"
assert snap["truncated"] is False
# One key set across real and not_found entries — uniform consumer
# access, no per-state conditionals (updated/name empty here).
assert set(snap) == {"state", "tokens", "updated", "name", "message", "truncated"}
assert snap["updated"] == ""
assert snap["name"] == ""
def test_wait_for_workstream_running_child_message_is_null(populated_storage):
+45
View File
@@ -8,6 +8,8 @@ visitor lands on the page but all API calls fail).
from __future__ import annotations
import re
import pytest
from starlette.applications import Starlette
from starlette.routing import Route
@@ -401,6 +403,49 @@ def test_coord_dedups_system_turn_against_history_by_event_id():
"false-skip after clear_ui / replay_truncated."
)
# The seam must be wired on BOTH read paths, not merely present somewhere
# in the file — a refactor that keeps the Set but drops the live-handler
# consultation (or the history-side record) silently re-opens the
# double-render. Scope each assertion to its block so the wiring, not the
# bare symbol, is pinned. (A dedupe-neutered factory — guard short-circuited
# to ``false`` — still contains ``renderedSystemEventIds.has(`` and so
# passes the file-global checks above; these slice checks catch it.)
sys_case = body.index('case "system_turn":')
# End at the NEXT switch case, not the first ``break;`` — the dedup-skip
# ``...has(sysEid)) break;`` is itself a break that precedes the ``.add(``,
# so a ``break;``-bounded slice would drop the record half.
# Whitespace-tolerant so a reformat can't silently break the bound.
next_case = re.search(r'\n\s*case "', body[sys_case + 1 :])
assert next_case, (
"no switch case found after system_turn to bound the pin slice — if "
"system_turn became the last case, re-anchor this pin's end marker."
)
live_block = body[sys_case : sys_case + 1 + next_case.start()]
assert "renderedSystemEventIds.has(" in live_block, (
"the live system_turn handler must CONSULT the dedup set (skip an id "
"already painted from /history) — not just reference the Set elsewhere."
)
assert "renderedSystemEventIds.add(" in live_block, (
"the live system_turn handler must RECORD the id it renders so a later "
"/history re-render (clear_ui) doesn't repaint it."
)
# The history render path must seed the set from each replayed system row's
# event_id, so a subsequent live replay of the same id is skipped. Bound
# the slice structurally — from the system-role branch to the next role
# branch in the same chain (falling back to a generous window when it's
# the last branch) — so adding comments/fields inside the branch can't
# false-fail a pin that only cares about the wiring.
assert 'role === "system"' in body
sys_replay = body.index('role === "system"', body.index("refetchHistory"))
next_role = re.search(r"role\s*===", body[sys_replay + 1 :])
replay_end = sys_replay + 1 + next_role.start() if next_role else sys_replay + 1500
replay_window = body[sys_replay:replay_end]
assert "renderedSystemEventIds.add(" in replay_window, (
"the history render's system-role branch must record each replayed "
"turn's event_id so the live system_turn handler can dedup against it."
)
def test_coord_retry_walk_skips_operator_context_cards():
"""Retry must NOT regenerate a stale assistant turn when the last DOM row is
+213
View File
@@ -0,0 +1,213 @@
"""Tests for docker/healthcheck.py — the container health probe.
Drives the real script via subprocess against real local listeners (plain
HTTP and mTLS with lacme-minted certs, the same CA path production uses),
mirroring how Docker invokes it.
"""
from __future__ import annotations
import http.server
import json
import os
import ssl
import subprocess
import sys
import threading
from pathlib import Path
import pytest
lacme = pytest.importorskip("lacme")
SCRIPT = Path(__file__).parent.parent / "docker" / "healthcheck.py"
def run_healthcheck(url: str, pem_root: Path | None = None) -> subprocess.CompletedProcess:
env = dict(os.environ)
# Point the script at the test's PEM root — or at an empty dir to model
# a plain-HTTP node with no TLS material on disk.
env["TURNSTONE_TLS_PEM_DIR"] = str(pem_root) if pem_root else "/nonexistent"
return subprocess.run(
[sys.executable, str(SCRIPT), url],
capture_output=True,
text=True,
timeout=30,
env=env,
)
class _Handler(http.server.BaseHTTPRequestHandler):
payload = {"status": "ok"}
def do_GET(self):
body = json.dumps(self.payload).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args):
pass
def _serve(handler_cls, ssl_context: ssl.SSLContext | None = None) -> int:
"""Start a daemon-thread HTTP(S) server on an ephemeral port."""
httpd = http.server.HTTPServer(("127.0.0.1", 0), handler_cls)
if ssl_context is not None:
httpd.socket = ssl_context.wrap_socket(httpd.socket, server_side=True)
threading.Thread(target=httpd.serve_forever, daemon=True).start()
return httpd.server_address[1]
@pytest.fixture
def mtls_setup(tmp_path):
"""Mint a CA + node cert exactly as the server does, write PEM files
under a runtime root, and build an mTLS server context requiring
client certs (mirrors uvicorn's ssl_cert_reqs=CERT_REQUIRED)."""
from lacme import CertificateAuthority, MemoryStore
from lacme.mtls import write_pem_files
from turnstone.core.tls import build_cert_hostnames
ca = CertificateAuthority(store=MemoryStore())
ca.init()
bundle = ca.issue(build_cert_hostnames("http://node-1:8080", bind_host="0.0.0.0"))
pem_root = tmp_path / "turnstone-tls"
pem_root.mkdir()
paths = write_pem_files(bundle, ca_pem=ca.root_cert_pem, directory=pem_root)
server_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
server_ctx.load_cert_chain(str(paths.cert), str(paths.key))
server_ctx.load_verify_locations(str(paths.ca))
server_ctx.verify_mode = ssl.CERT_REQUIRED
return pem_root, server_ctx
# ── Plain HTTP (mTLS disabled — the default deployment) ─────────────────────
def test_plain_http_ok():
"""Default path: plain probe succeeds, PEM dir never consulted."""
port = _serve(_Handler)
result = run_healthcheck(f"http://127.0.0.1:{port}/health")
assert result.returncode == 0, result.stderr
def test_plain_http_degraded_is_healthy():
"""'degraded' (backend down, server up) still counts as container-healthy."""
class Degraded(_Handler):
payload = {"status": "degraded"}
port = _serve(Degraded)
result = run_healthcheck(f"http://127.0.0.1:{port}/health")
assert result.returncode == 0, result.stderr
def test_plain_http_bad_status_fails():
class Bad(_Handler):
payload = {"status": "error"}
port = _serve(Bad)
result = run_healthcheck(f"http://127.0.0.1:{port}/health")
assert result.returncode == 1
assert "unhealthy payload" in result.stderr
def test_server_down_fails():
"""Nothing listening: fail, with or without PEM material around."""
result = run_healthcheck("http://127.0.0.1:9/health")
assert result.returncode == 1
assert "Health check failed" in result.stderr
# ── mTLS (tls.enabled) ───────────────────────────────────────────────────────
def test_mtls_probe_with_pem_dir(mtls_setup):
"""The regression case: mTLS node + plain-HTTP probe URL.
The plain attempt is rejected at the socket; the script must fall back
to HTTPS with the node cert as client cert and report healthy."""
pem_root, server_ctx = mtls_setup
port = _serve(_Handler, ssl_context=server_ctx)
result = run_healthcheck(f"http://127.0.0.1:{port}/health", pem_root=pem_root)
assert result.returncode == 0, result.stderr
def test_mtls_probe_without_pems_fails(mtls_setup):
"""mTLS node but no PEM material on disk: the probe must fail."""
_, server_ctx = mtls_setup
port = _serve(_Handler, ssl_context=server_ctx)
result = run_healthcheck(f"http://127.0.0.1:{port}/health", pem_root=None)
assert result.returncode == 1
assert "Health check failed" in result.stderr
def test_mtls_unhealthy_payload_fails(mtls_setup):
"""A reachable mTLS server with a bad payload is still unhealthy."""
pem_root, server_ctx = mtls_setup
class Bad(_Handler):
payload = {"status": "error"}
port = _serve(Bad, ssl_context=server_ctx)
result = run_healthcheck(f"http://127.0.0.1:{port}/health", pem_root=pem_root)
assert result.returncode == 1
assert "unhealthy payload" in result.stderr
def test_mtls_incomplete_pem_dir_fails(mtls_setup, tmp_path):
"""A PEM dir missing the key is skipped, not half-used."""
_, server_ctx = mtls_setup
incomplete = tmp_path / "incomplete-root"
d = incomplete / "lacme-pem-x"
d.mkdir(parents=True)
(d / "fullchain.pem").write_text("not a cert")
(d / "ca.pem").write_text("not a cert")
port = _serve(_Handler, ssl_context=server_ctx)
result = run_healthcheck(f"http://127.0.0.1:{port}/health", pem_root=incomplete)
assert result.returncode == 1
# ── Drift guards (script re-encodes contracts it cannot import) ──────────────
def _load_script_module():
"""Load healthcheck.py as a module — docker/ is not a package."""
import importlib.util
spec = importlib.util.spec_from_file_location("healthcheck_script", SCRIPT)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_default_pem_root_matches_server(monkeypatch):
"""Drift guard: the script's default PEM root equals the server's.
The script cannot import turnstone (standalone stdlib), so the default
path literal is re-encoded; a rename on either side must fail here, not
silently break mTLS probing in production."""
from turnstone.core.tls import tls_pem_runtime_dir
monkeypatch.delenv("TURNSTONE_TLS_PEM_DIR", raising=False)
assert _load_script_module()._pem_root() == tls_pem_runtime_dir()
def test_find_pem_dir_accepts_real_pem_layout(monkeypatch, mtls_setup):
"""Drift guard: lacme's on-disk layout is accepted by _find_pem_dir.
Pins the lacme-pem-* dir prefix and the fullchain/key/ca filename
triplet against real write_pem_files output."""
pem_root, _ = mtls_setup
monkeypatch.setenv("TURNSTONE_TLS_PEM_DIR", str(pem_root))
found = _load_script_module()._find_pem_dir()
assert found is not None
assert found.parent == pem_root
+219
View File
@@ -0,0 +1,219 @@
"""Static smoke guards for the service-hatch container system.
``shared_static/hatch.{css,js}`` is the admin shelf/dialog chrome (the modal
redesign): a pane-scoped NON-modal shelf for create/edit/inspect and a
document-modal dialog tier for confirms/show-once. Like the rest of the
WebUI there is no JS test framework, so the load-bearing invariants are
pinned as Python-side string-presence assertions.
"""
from __future__ import annotations
import re
from pathlib import Path
_ROOT = Path(__file__).resolve().parent.parent
_HATCH_JS = _ROOT / "turnstone/shared_static/hatch.js"
_HATCH_CSS = _ROOT / "turnstone/shared_static/hatch.css"
_CONSOLE_INDEX = _ROOT / "turnstone/console/static/index.html"
_UI_INDEX = _ROOT / "turnstone/ui/static/index.html"
def test_shelf_is_nonmodal_and_dialog_is_modal() -> None:
"""The TIERING invariant: the shelf opens with non-modal ``show()`` (the
pane stays the containing block, other panes stay live the split-pane
contract) while the confirm tier opens with ``showModal()`` (top layer,
stacks above any shelf)."""
body = _HATCH_JS.read_text(encoding="utf-8")
assert "dlg.show();" in body, "openShelf must use non-modal show()"
assert "dlg.showModal();" in body, "openDialog must use showModal()"
# The shelf path must NOT fall back to showModal — top layer cannot be
# bound to a pane, which silently breaks the split-pane contract.
shelf_fn = body.split("export function openShelf", 1)[1].split("export function", 1)[0]
assert "showModal" not in shelf_fn
def test_shelf_focus_containment_is_inert_on_pane_siblings() -> None:
"""Non-modal means no free focus trap: containment comes from ``inert``
on the host pane's OTHER children, restored on close."""
body = _HATCH_JS.read_text(encoding="utf-8")
assert "el.inert = true;" in body
assert "el.inert = false;" in body
# Pre-existing inertness must be respected, not clobbered on restore.
assert "if (el.inert) continue;" in body
def test_shelf_escape_defers_to_a_modal_above() -> None:
"""Controller-owned Escape (non-modal dialogs have no native cancel)
must NOT double-close when a document-modal confirm sits above the
shelf the native dialog owns that Escape."""
body = _HATCH_JS.read_text(encoding="utf-8")
assert 'document.querySelector("dialog:modal")' in body
def test_busy_lock_refuses_dismissal() -> None:
"""While a submit is in flight (data-busy) the container must hold:
Escape, scrim clicks, [data-close], keyboard re-submit, and the dialog
tier's native cancel are ALL refused."""
body = _HATCH_JS.read_text(encoding="utf-8")
assert 'top.hasAttribute("data-busy")' in body, "Escape must check busy"
assert 'dlg.hasAttribute("data-busy")' in body, "data-close must check busy"
# Scrim click mid-flight must not close the shelf.
scrim_handler = body.split('scrim.addEventListener("click"', 1)[1].split("});", 1)[0]
assert 'hasAttribute("data-busy")' in scrim_handler, (
"the scrim click handler must hold the door while busy"
)
# Enter on the focused primary dispatches a click — a capture-phase guard
# must swallow it before surface submit handlers re-fire the request.
assert "{ capture: true }" in body, "busy needs the capture-phase guard"
# The dialog tier's native Escape arrives as `cancel`.
assert 'addEventListener("cancel"' in body, "openDialog must intercept cancel while busy"
# Busy is announced, not just painted.
assert 'setAttribute("aria-busy", "true")' in body
def test_window_bridge_for_classic_scripts() -> None:
"""admin.js/governance.js are classic scripts; they reach the ESM
controller via the transitional window bridge (the toast.js pattern)."""
body = _HATCH_JS.read_text(encoding="utf-8")
assert "window.TurnstoneHatch = { openShelf, closeShelf, openDialog, setBusy };" in body
def test_console_loads_hatch_assets() -> None:
html = _CONSOLE_INDEX.read_text(encoding="utf-8")
assert '<link rel="stylesheet" href="/shared/hatch.css" />' in html
assert '<script type="module" src="/shared/hatch.js"></script>' in html
def test_ui_loads_hatch_assets() -> None:
html = _UI_INDEX.read_text(encoding="utf-8")
assert '<link rel="stylesheet" href="/shared/hatch.css" />' in html
assert '<script type="module" src="/shared/hatch.js"></script>' in html
def test_css_base_selector_is_class_only() -> None:
"""``dialog.hatch`` (0,1,1) would out-rank the container surface rules
(0,1,0) and re-introduce the transparent-shelf bug the base selector
must stay class-only with containers winning on source order."""
css = _HATCH_CSS.read_text(encoding="utf-8")
assert re.search(r"^dialog\.hatch\b", css, flags=re.M) is None
assert "\n.hatch {" in css
assert css.index("\n.hatch {") < css.index(".hatch--shelf {"), (
"containers must come AFTER the base rule to win on source order"
)
def test_css_dialog_tier_restores_ua_centering() -> None:
"""The global reset flattens the UA's ``margin: auto`` that centers a
modal dialog the dialog tier must restore it."""
css = _HATCH_CSS.read_text(encoding="utf-8")
dialog_rule = css.split(".hatch--dialog {", 1)[1].split("}", 1)[0]
assert "margin: auto;" in dialog_rule
def test_css_sheet_breakpoint_is_a_container_query() -> None:
"""A narrow SPLIT pane is narrow on a wide viewport: the bottom-sheet
degradation keys off the PANE's width (@container), not the viewport."""
css = _HATCH_CSS.read_text(encoding="utf-8")
assert "container-type: inline-size;" in css
assert "@container pane (max-width: 700px)" in css
def test_css_reduced_motion_and_light_theme_pass() -> None:
css = _HATCH_CSS.read_text(encoding="utf-8")
assert "@media (prefers-reduced-motion: reduce)" in css
# The light-theme micro-text contrast pass (the .tab-menu-key precedent:
# --ink-4 is sub-AA at 11px on light surfaces — one step up).
assert '[data-theme="light"] .sh-foot-meta' in css
def test_hatch_markup_shape() -> None:
"""Every ``dialog.hatch`` in the console AND ui markup carries the full
anatomy: a tier class, sh-head/sh-body/sh-foot, and aria-labelledby."""
for index in (_CONSOLE_INDEX, _UI_INDEX):
html = index.read_text(encoding="utf-8")
for m in re.finditer(r"<dialog\b[^>]*class=\"[^\"]*\bhatch\b[^\"]*\"[^>]*>", html):
tag = m.group(0)
assert "hatch--shelf" in tag or "hatch--dialog" in tag, f"{index.name}: {tag}"
assert 'aria-labelledby="' in tag, f"{index.name}: missing aria-labelledby: {tag}"
# The dialog's body (up to its close tag) must have the three strips.
rest = html[m.end() : html.index("</dialog>", m.end())]
for cls in ("sh-head", "sh-body", "sh-foot"):
assert cls in rest, f"{index.name}: dialog missing .{cls}: {tag}"
def test_classic_scripts_use_the_bridge_only_at_handler_time() -> None:
"""Module evaluation is deferred: a classic script touching
``TurnstoneHatch`` at parse time boots before the bridge exists (the
#644 const-initializer lesson). Heuristic guard: no top-level
``TurnstoneHatch`` use every reference must sit inside a function
body (indented)."""
classic = [
_ROOT / "turnstone/console/static" / name
for name in ("admin.js", "governance.js", "app.js")
]
classic.append(_ROOT / "turnstone/ui/static/app.js")
for path in classic:
if not path.exists():
continue
for i, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
if "TurnstoneHatch" in line and not line.startswith((" ", "\t")):
raise AssertionError(
f"{path.name}:{i}: top-level TurnstoneHatch reference — "
"the window bridge only exists after modules evaluate"
)
def test_every_hatch_button_is_wired() -> None:
"""The bug class that shipped a dead model-Save button: converted markup
drops inline onclick=, so every id-bearing non-[data-close] button inside
a dialog.hatch MUST have JS wiring a direct .onclick/.addEventListener
on its getElementById, or wiring through the variable it's assigned to.
(data-close and container-delegated id-less buttons are hatch-owned.)"""
js_by_app = {
"console": [
_ROOT / "turnstone/console/static/admin.js",
_ROOT / "turnstone/console/static/governance.js",
_ROOT / "turnstone/console/static/app.js",
_ROOT / "turnstone/shared_static/cards.js",
],
"ui": [
_ROOT / "turnstone/ui/static/app.js",
_ROOT / "turnstone/shared_static/cards.js",
],
}
# Built via cards.js's `$("${idPrefix}-confirm-btn")` helper — the literal
# id never appears in JS; the wiring is delBtn.onclick in confirm().
allowlist = {"ws-delete-confirm-btn", "coord-delete-confirm-btn"}
for app, index in (("console", _CONSOLE_INDEX), ("ui", _UI_INDEX)):
html = index.read_text(encoding="utf-8")
js = "\n".join(p.read_text(encoding="utf-8") for p in js_by_app[app])
for dm in re.finditer(r"<dialog\b[^>]*class=\"[^\"]*\bhatch\b[^\"]*\"[^>]*>", html):
body = html[dm.end() : html.index("</dialog>", dm.end())]
for bm in re.finditer(r"<button\b[^>]*>", body):
tag = bm.group(0)
if "data-close" in tag:
continue
idm = re.search(r'id="([^"]+)"', tag)
if not idm or idm.group(1) in allowlist:
continue
bid = re.escape(idm.group(1))
direct = re.search(
rf'getElementById\(\s*"{bid}"\s*\)[\s\S]{{0,120}}?\.(?:onclick|addEventListener)',
js,
)
wired = bool(direct)
if not wired:
for vm in re.finditer(
rf'(?:const|var|let)\s+(\w+)\s*=\s*document\.getElementById\(\s*"{bid}"\s*\)',
js,
):
var = re.escape(vm.group(1))
if re.search(rf"\b{var}\s*\.\s*(?:onclick|addEventListener)", js):
wired = True
break
assert wired, (
f"{app}: button #{idm.group(1)} inside a dialog.hatch has no "
"click wiring — the converted markup has no onclick, so an "
"unwired button is silently dead (the model-Save bug class)"
)
+65
View File
@@ -0,0 +1,65 @@
"""/health surfaces the node's TLS state when tls.enabled is configured.
A node that falls back to plain HTTP after a failed TLS init must be
observable (tls: "fallback"), and default plain-HTTP deployments must keep
an unchanged payload shape (no "tls" key).
"""
from __future__ import annotations
import queue
import threading
from unittest.mock import MagicMock
import pytest
@pytest.fixture()
def make_client():
from starlette.testclient import TestClient
from turnstone.server import create_app
clients = []
def _make(tls_state: str | None = None):
mock_mgr = MagicMock()
mock_mgr.list_all.return_value = []
mock_mgr.max_active = 10
app = create_app(
workstreams=mock_mgr,
global_queue=queue.Queue(),
global_listeners=[],
global_listeners_lock=threading.Lock(),
skip_permissions=False,
jwt_secret="test-jwt-secret-minimum-32-chars!",
)
if tls_state is not None:
app.state.tls_state = tls_state
client = TestClient(app, raise_server_exceptions=False)
clients.append(client)
return client
yield _make
for c in clients:
c.close()
def test_health_no_tls_key_by_default(make_client):
"""mTLS disabled (default): payload shape unchanged — no tls key."""
resp = make_client().get("/health")
assert resp.status_code == 200
assert "tls" not in resp.json()
def test_health_tls_active(make_client):
resp = make_client(tls_state="active").get("/health")
assert resp.status_code == 200
assert resp.json()["tls"] == "active"
def test_health_tls_fallback_visible(make_client):
"""The silent-downgrade case must be observable in /health."""
resp = make_client(tls_state="fallback").get("/health")
assert resp.status_code == 200
assert resp.json()["tls"] == "fallback"
+48 -11
View File
@@ -24,12 +24,16 @@ class TestBuildVerdictPayload:
"""The wire-shape projection that's the single source of truth for
what intent_verdict fields ship to the client."""
def test_skips_unflagged_baseline(self) -> None:
"""``risk_level`` "none" is the unflagged-tool baseline; the
client filters those anyway, so projecting None at the wire
layer keeps the payload tight on long workstreams."""
row = {"risk_level": "none", "recommendation": "approve", "tier": "heuristic"}
assert build_verdict_payload(row) is None
def test_ships_unflagged_baseline(self) -> None:
"""``risk_level`` "none" rows ship like any other — the live
path paints a badge for every delivered verdict (the client
has no risk filter), so replay must carry the same set or
benign verdicts vanish on rehydrate (live/replay parity)."""
row = {"risk_level": "none", "recommendation": "approve", "tier": "llm"}
out = build_verdict_payload(row)
assert out["risk_level"] == "none"
assert out["recommendation"] == "approve"
assert out["tier"] == "llm"
def test_drops_call_id_and_func_name(self) -> None:
"""The client already has these on ``tc.id`` / ``tc.name``;
@@ -243,13 +247,14 @@ class TestDecorateToolCall:
decorate_tool_call(tc, verdicts, {})
assert "verdict" not in tc
def test_skips_unflagged_verdict(self) -> None:
"""``build_verdict_payload`` returns None for unflagged rows;
decorate_tool_call must not stamp ``verdict`` in that case."""
def test_stamps_unflagged_verdict(self) -> None:
"""A ``risk_level="none"`` row still stamps ``verdict`` — the
operator saw the badge live, so it must survive rehydrate."""
tc: dict[str, object] = {"id": "call_1", "name": "bash"}
verdicts = {"call_1": {"risk_level": "none", "tier": "heuristic"}}
verdicts = {"call_1": {"risk_level": "none", "tier": "llm"}}
decorate_tool_call(tc, verdicts, {})
assert "verdict" not in tc
assert "verdict" in tc
assert tc["verdict"]["risk_level"] == "none" # type: ignore[index]
def test_handles_empty_id(self) -> None:
"""A tool_call with no id can't be paired against the lookup
@@ -311,6 +316,38 @@ class TestDecorateHistoryMessages:
assert messages[3]["content"] == "short"
assert "advisories" not in messages[3]
def test_parallel_batch_keeps_every_judged_verdict(self) -> None:
"""Regression: a parallel batch where the judge cleared most
calls (``risk_level="none"``) must rehydrate with a verdict on
EVERY judged call, not just the flagged minority. The old
wire-layer ``none`` filter made benign verdicts vanish after a
restart while the live stream had shown all of them."""
calls = [f"call_{i}" for i in range(8)]
verdicts = {
cid: {
"risk_level": "low" if i < 2 else "none",
"recommendation": "approve",
"confidence": 0.9,
"intent_summary": f"benign op {i}",
"tier": "llm",
}
for i, cid in enumerate(calls)
}
messages: list[dict[str, object]] = [
{
"role": "assistant",
"content": "",
"tool_calls": [
{"id": cid, "function": {"name": "read_file", "arguments": "{}"}}
for cid in calls
],
},
]
decorate_history_messages(messages, verdicts, {})
tool_calls = messages[0]["tool_calls"] # type: ignore[index]
decorated = [tc["verdict"]["risk_level"] for tc in tool_calls]
assert decorated == ["low", "low"] + ["none"] * 6
def test_no_op_on_empty_indexes(self) -> None:
"""When neither table has rows for the workstream, the wire
shape passes through unchanged replay must degrade
+36
View File
@@ -184,6 +184,42 @@ def test_approval_keyboard_shortcuts_wired() -> None:
)
def test_media_playback_lifted_and_pane_owned() -> None:
"""The media Play affordance is rendered by the pane (buildPlayButton /
buildMediaEmbed), so its activation must live in the pane too the old
standalone wired a DOCUMENT-level click/keydown listener in app.js, which
the console host never loaded (so the button was dead in console-hosted
panes). The fix mirrors the approval-keydown pattern: a pane-owned listener
on this.el, root-scoped via closest(".media-play-btn"). Pin both the
lifted helpers and the pane wiring so the document-level regression can't
silently come back."""
body = _INTERACTIVE.read_text(encoding="utf-8")
# The lifted activation machinery now lives in the shared module.
for fn in (
"function _loadHls(",
"function _isHlsUrl(",
"function _activatePlayer(",
"function activateMediaPlayButton(",
):
assert fn in body, f"media player helper must be lifted into the pane: {fn}"
# The HLS vendor is fetched by absolute /shared/ URL (resolves in BOTH the
# standalone server and the console, where /shared is mounted at the root).
assert 'script.src = "/shared/hls-1.6.16/hls.min.js";' in body
# Pane-owned + root-scoped — NOT a document-level delegated listener.
assert 'this.el.addEventListener("click"' in body, (
"media play must be wired on this.el (pane-owned), not document"
)
assert 'e.target.closest(".media-play-btn")' in body, (
"the play handler must be root-scoped via closest, not a document-wide id"
)
assert "activateMediaPlayButton(btn)" in body
collapsed = _strip_comments(body)
assert 'document.addEventListener("click"' not in collapsed, (
"the pane must not register a document-level click delegate — that is "
"the standalone regression that left console panes dead"
)
def test_controller_terminal_dead_state() -> None:
"""Lifecycle round 2: the console controller must STOP reconnect-polling a
session that is gone (closed / evicted / node restarted) three consecutive
+70
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import json
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
@@ -297,6 +298,75 @@ class TestErrorHandling:
assert provider.create_completion.call_count == 1
# ---------------------------------------------------------------------------
# Cancel-event semantics
# ---------------------------------------------------------------------------
def _wait_for(results: list[IntentVerdict], count: int, timeout: float = 5.0) -> None:
deadline = time.monotonic() + timeout
while len(results) < count and time.monotonic() < deadline:
time.sleep(0.02)
class TestCancelEventSemantics:
"""The cancel event is an unconditional abort signal at the judge
layer: once it fires, no further inference is spent and every undone
item degrades to an ``llm_fallback`` verdict (heuristic-derived). WHO fires it is
ChatSession policy (always on generation supersede / close; on
approval resolution only when ``cancel_on_approval`` is enabled)
this loop must not second-guess the signal against its own config,
which is what previously broke the run-to-completion contract."""
def test_fired_event_aborts_with_default_config(self):
provider = _make_mock_provider(_good_verdict_json())
judge = _make_judge(provider)
assert judge._config.cancel_on_approval is False # pin the default
cancel = threading.Event()
cancel.set() # supersede/close happened before the daemon started
results: list[IntentVerdict] = []
items = [_make_item(call_id=f"tc_{i}") for i in range(3)]
judge.evaluate(
items,
[{"role": "user", "content": "test"}],
results.append,
cancel_event=cancel,
)
_wait_for(results, 3)
# Every item still gets exactly one verdict (Smart Approvals and
# the advisory UI wait on the full set) — all fallbacks...
assert [v.call_id for v in results] == ["tc_0", "tc_1", "tc_2"]
assert all(v.tier == "llm_fallback" for v in results)
assert all("cancelled" in v.reasoning for v in results)
# ...and no inference was spent after the abort signal.
assert provider.create_completion.call_count == 0
def test_unfired_event_runs_every_item_with_default_config(self):
"""The run-to-completion contract: with cancel_on_approval=False
and no abort signal, all items get REAL LLM verdicts resolving
the gate must not have fired the event (that's pinned on the
session side), and this loop must keep evaluating."""
provider = _make_mock_provider(_good_verdict_json())
judge = _make_judge(provider)
cancel = threading.Event() # never fired
results: list[IntentVerdict] = []
items = [_make_item(call_id=f"tc_{i}") for i in range(3)]
judge.evaluate(
items,
[{"role": "user", "content": "test"}],
results.append,
cancel_event=cancel,
)
_wait_for(results, 3)
assert [v.call_id for v in results] == ["tc_0", "tc_1", "tc_2"]
assert all(v.tier == "llm" for v in results)
assert provider.create_completion.call_count == 3
# ---------------------------------------------------------------------------
# Multi-turn tool use
# ---------------------------------------------------------------------------
+34
View File
@@ -287,6 +287,40 @@ class TestIntentVerdictBulkInsert:
assert v1["risk_level"] == "low" and v1["tier"] == "heuristic"
assert v2["risk_level"] == "high" and v2["tier"] == "llm"
def test_bulk_insert_pk_collision_skips_only_colliding_row(self, db):
"""Regression: the async judge daemon can UPSERT a fallback row —
reusing a heuristic verdict_id from the incoming batch BEFORE
``approve_tools`` runs the bulk write. The bulk insert must skip
just that row (keeping the daemon's tier upgrade) instead of
aborting the whole statement and silently losing every sibling
row in the batch."""
# Daemon won the race: fallback row already sits on b2's PK.
db.upsert_intent_verdict(
**_make_verdict_kwargs(
verdict_id="b2",
call_id="c2",
tier="llm_fallback",
judge_model="judge-model",
)
)
db.create_intent_verdicts_bulk(
[
_make_verdict_kwargs(verdict_id="b1", call_id="c1"),
_make_verdict_kwargs(verdict_id="b2", call_id="c2"), # collides
_make_verdict_kwargs(verdict_id="b3", call_id="c3"),
]
)
# Siblings landed despite the mid-batch collision.
for vid in ("b1", "b3"):
v = db.get_intent_verdict(vid)
assert v is not None, f"sibling row {vid} lost to the collision"
assert v["tier"] == "heuristic"
# The colliding row kept the daemon's upgrade, not the bulk stamp.
v2 = db.get_intent_verdict("b2")
assert v2 is not None
assert v2["tier"] == "llm_fallback"
assert v2["judge_model"] == "judge-model"
# ---------------------------------------------------------------------------
# List queries
+116 -17
View File
@@ -4,9 +4,10 @@ from __future__ import annotations
import asyncio
import concurrent.futures
import inspect
import json
import time
from contextlib import AsyncExitStack
from contextlib import AsyncExitStack, suppress
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
@@ -26,6 +27,26 @@ from turnstone.core.tools import INTERACTIVE_TOOLS, TOOLS, merge_mcp_tools
# ---------------------------------------------------------------------------
def _dispatch_stub(mock_future: MagicMock) -> Any:
"""Stand-in for ``asyncio.run_coroutine_threadsafe`` in sync-bridge tests.
Closes the never-scheduled coroutine before handing back the canned
future a mocked dispatch never awaits it, and an unawaited coroutine
GC-fires "coroutine ... was never awaited" inside whatever unrelated
test happens to be running when collection finally occurs (cross-test
bleed that per-test filterwarnings markers cannot catch).
"""
def _rct(coro: Any, _loop: Any) -> MagicMock:
# Only real coroutines need (or survive) closing — several tests
# dispatch a plain MagicMock return value through this seam.
if inspect.iscoroutine(coro):
coro.close()
return mock_future
return _rct
def _fake_mcp_tool(name: str = "search", description: str = "Search stuff") -> MagicMock:
"""Create a mock MCP tool object matching the SDK's Tool type."""
tool = MagicMock()
@@ -153,8 +174,24 @@ def running_loop_mgr():
try:
yield mgr, loop, thread
finally:
# Drain BEFORE stopping: a task left pending (or finished-but-
# unretrieved) on a stopped loop becomes cross-test global state —
# asyncio reports it at GC time, mid-suite, onto whatever stream
# pytest has attached THEN (the "I/O operation on closed file"
# spew), and a silently-abandoned loop thread keeps running
# manager code against torn-down mocks.
async def _cancel_pending() -> None:
tasks = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]
for t in tasks:
t.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
with suppress(Exception):
asyncio.run_coroutine_threadsafe(_cancel_pending(), loop).result(timeout=5)
loop.call_soon_threadsafe(loop.stop)
thread.join(timeout=2)
thread.join(timeout=5)
assert not thread.is_alive(), "mcp test loop thread failed to stop within 5s"
loop.close()
# ---------------------------------------------------------------------------
@@ -2024,6 +2061,37 @@ class TestShutdownCleanup:
assert mgr._resource_map == {}
assert mgr._prompt_map == {}
def test_shutdown_closes_owned_loop_and_clears_refs(self):
"""When the manager owns the loop thread, shutdown must close the loop
(selector resources leak otherwise) and drop both refs; a second
shutdown is then a clean no-op."""
import threading as _threading
mgr = MCPClientManager({})
loop = asyncio.new_event_loop()
thread = _threading.Thread(target=loop.run_forever, daemon=True)
thread.start()
mgr._loop = loop
mgr._thread = thread
mgr.shutdown()
assert loop.is_closed()
assert mgr._loop is None
assert mgr._thread is None
mgr.shutdown() # idempotent
def test_shutdown_leaves_unowned_loop_open(self):
"""Tests (and any embedder) that wire ``_loop`` directly without a
thread own the loop's lifecycle — shutdown must not close it."""
mgr = MCPClientManager({})
loop = asyncio.new_event_loop()
mgr._loop = loop
try:
mgr.shutdown()
assert not loop.is_closed()
finally:
loop.close()
# ---------------------------------------------------------------------------
# TCP probe and unreachable server handling
@@ -2181,7 +2249,7 @@ class TestFutureCancellation:
mock_future = MagicMock()
mock_future.result.side_effect = concurrent.futures.TimeoutError()
with (
patch("asyncio.run_coroutine_threadsafe", return_value=mock_future),
patch("asyncio.run_coroutine_threadsafe", new=_dispatch_stub(mock_future)),
pytest.raises(TimeoutError, match="timed out"),
):
mgr.call_tool_sync("mcp__test__search", {"query": "x"}, timeout=1)
@@ -2192,7 +2260,7 @@ class TestFutureCancellation:
mock_future = MagicMock()
mock_future.result.side_effect = concurrent.futures.TimeoutError()
with (
patch("asyncio.run_coroutine_threadsafe", return_value=mock_future),
patch("asyncio.run_coroutine_threadsafe", new=_dispatch_stub(mock_future)),
pytest.raises(TimeoutError, match="timed out"),
):
mgr.read_resource_sync("file:///a.txt", timeout=1)
@@ -2203,7 +2271,7 @@ class TestFutureCancellation:
mock_future = MagicMock()
mock_future.result.side_effect = concurrent.futures.TimeoutError()
with (
patch("asyncio.run_coroutine_threadsafe", return_value=mock_future),
patch("asyncio.run_coroutine_threadsafe", new=_dispatch_stub(mock_future)),
pytest.raises(TimeoutError, match="timed out"),
):
mgr.get_prompt_sync("mcp__test__review", timeout=1)
@@ -2216,7 +2284,7 @@ class TestFutureCancellation:
mock_future.result.side_effect = concurrent.futures.TimeoutError()
with (
patch.object(mgr, "_refresh_all", return_value=MagicMock()),
patch("asyncio.run_coroutine_threadsafe", return_value=mock_future),
patch("asyncio.run_coroutine_threadsafe", new=_dispatch_stub(mock_future)),
pytest.raises(TimeoutError, match="timed out"),
):
mgr.refresh_sync(timeout=1)
@@ -2331,8 +2399,6 @@ class TestCircuitBreaker:
assert "srv" not in mgr._circuit_open_until
assert "srv" not in mgr._circuit_trip_count
@pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning")
@pytest.mark.filterwarnings("ignore:coroutine.*was never awaited:RuntimeWarning")
def test_call_tool_sync_records_failure_on_timeout(self):
mgr = MCPClientManager({"test": {"type": "stdio", "command": "echo"}})
mock_session = MagicMock()
@@ -2343,7 +2409,7 @@ class TestCircuitBreaker:
mock_future = MagicMock()
mock_future.result.side_effect = concurrent.futures.TimeoutError()
with (
patch("asyncio.run_coroutine_threadsafe", return_value=mock_future),
patch("asyncio.run_coroutine_threadsafe", new=_dispatch_stub(mock_future)),
pytest.raises(TimeoutError),
):
mgr.call_tool_sync("mcp__test__ping", {}, timeout=1)
@@ -2363,7 +2429,7 @@ class TestCircuitBreaker:
mock_result.isError = False
mock_future = MagicMock()
mock_future.result.return_value = mock_result
with patch("asyncio.run_coroutine_threadsafe", return_value=mock_future):
with patch("asyncio.run_coroutine_threadsafe", new=_dispatch_stub(mock_future)):
mgr.call_tool_sync("mcp__test__ping", {}, timeout=5)
assert mgr._consecutive_failures.get("test") is None
@@ -2380,7 +2446,7 @@ class TestCircuitBreaker:
mock_future = MagicMock()
mock_future.result.side_effect = BrokenPipeError("dead")
with (
patch("asyncio.run_coroutine_threadsafe", return_value=mock_future),
patch("asyncio.run_coroutine_threadsafe", new=_dispatch_stub(mock_future)),
pytest.raises(BrokenPipeError),
):
mgr.call_tool_sync("mcp__test__ping", {}, timeout=5)
@@ -2414,7 +2480,7 @@ class TestCircuitBreaker:
mock_future = MagicMock()
mock_future.result.side_effect = McpError(ErrorData(code=-32601, message="tool not found"))
with (
patch("asyncio.run_coroutine_threadsafe", return_value=mock_future),
patch("asyncio.run_coroutine_threadsafe", new=_dispatch_stub(mock_future)),
pytest.raises(McpError),
):
mgr.call_tool_sync("mcp__test__ping", {}, timeout=5)
@@ -2706,11 +2772,22 @@ class TestCBAutoReconnectRefresh:
patch.object(mgr, "_refresh_server", side_effect=_refresh),
):
session = mgr._cb_auto_reconnect("srv")
# Wait for the scheduled refresh task to actually run on the loop.
# Wait for the scheduled refresh task to actually run on the loop,
# then for the tracked task to DRAIN — exiting the patch context
# while the task is still in flight would hand the un-patched
# method to its tail.
assert refresh_event.wait(timeout=5), "refresh task was not scheduled"
deadline = time.time() + 5
while mgr._background_tasks and time.time() < deadline:
time.sleep(0.02)
assert not mgr._background_tasks, "background refresh task never drained"
assert session is new_session
def test_auto_reconnect_swallows_refresh_failure(self, running_loop_mgr):
def test_auto_reconnect_retrieves_and_logs_refresh_failure(self, running_loop_mgr):
"""A refresh failure must be RETRIEVED and logged by the task's
done-callback not abandoned for asyncio to report as "Task exception
was never retrieved" at GC time (which lands on whatever stream pytest
has attached by then: the closed-file CI spew)."""
import threading as _threading
mgr, _loop, _thread = running_loop_mgr
@@ -2728,10 +2805,32 @@ class TestCBAutoReconnectRefresh:
with (
patch.object(mgr, "_connect_one", side_effect=_connect_one),
patch.object(mgr, "_refresh_server", side_effect=_refresh_failing),
patch("turnstone.core.mcp_client.log") as mock_log,
):
# Must not raise — refresh failures are non-fatal.
# Must not raise — refresh failures are non-fatal to the caller.
session = mgr._cb_auto_reconnect("srv")
# Background refresh actually started and exception was swallowed
# by the task without affecting the synchronous caller.
assert refresh_started.wait(timeout=5), "refresh task was not scheduled"
# Poll for the WARNING while the patch is still active — gating on
# set-emptiness alone would race the un-patch (review-caught: the
# warning could land on the restored real logger).
deadline = time.time() + 5
warn_calls = []
while not warn_calls and time.time() < deadline:
warn_calls = [
c for c in mock_log.warning.call_args_list if "MCP background" in str(c.args[0])
]
time.sleep(0.02)
# The tracked task must also fully drain (emptiness now implies
# "done AND reported" — discard is the callback's LAST step).
deadline = time.time() + 5
while mgr._background_tasks and time.time() < deadline:
time.sleep(0.02)
assert not mgr._background_tasks, "background refresh task never drained"
assert session is new_session
assert warn_calls, (
"the refresh failure must be logged by the done-callback, not left "
"for GC-time reporting"
)
exc = warn_calls[0].kwargs.get("exc_info")
assert isinstance(exc, RuntimeError)
assert "catalog fetch broke" in str(exc)
+19
View File
@@ -164,6 +164,25 @@ class TestProbeModelEndpoint:
assert result["server_type"] == "anthropic"
assert result["context_window"] == 1000000
@patch("turnstone.core.providers.create_client")
def test_anthropic_compatible_server_type(self, mock_cc: MagicMock) -> None:
m = _mock_model("deepseek-ai/DeepSeek-V4-Flash")
mock_cc.return_value = _mock_client(m)
result = probe_model_endpoint("anthropic-compatible", "http://localhost:8000", "dummy")
assert result["reachable"] is True
assert result["server_type"] == "anthropic-compatible"
assert result["context_window"] is None
@patch("turnstone.core.providers.create_client")
def test_anthropic_compatible_max_model_len(self, mock_cc: MagicMock) -> None:
m = _mock_model("deepseek-ai/DeepSeek-V4-Flash", max_model_len=131072)
mock_cc.return_value = _mock_client(m)
result = probe_model_endpoint("anthropic-compatible", "http://localhost:8000", "dummy")
assert result["context_window"] == 131072
assert result["server_type"] == "anthropic-compatible"
@patch("turnstone.core.providers.create_client")
def test_connection_failure(self, mock_cc: MagicMock) -> None:
mock_cc.side_effect = OSError("Connection refused")
+18
View File
@@ -142,6 +142,24 @@ def test_create_emits_models_changed(storage: SQLiteBackend) -> None:
assert collector.emit_models_changed.call_count == 1
def test_create_accepts_anthropic_compatible_provider(storage: SQLiteBackend) -> None:
"""anthropic-compatible passes the _MODEL_PROVIDERS enum check."""
client, collector = _make_client(storage)
resp = client.post(
"/v1/api/admin/model-definitions",
json={
"alias": "vllm-messages",
"model": "deepseek-ai/DeepSeek-V4-Flash",
"provider": "anthropic-compatible",
"base_url": "http://localhost:8000",
"api_key": "dummy",
"context_window": 131072,
},
)
assert resp.status_code == 200, resp.text
assert collector.emit_models_changed.call_count == 1
def test_update_emits_models_changed(storage: SQLiteBackend) -> None:
_seed(storage, definition_id="m1", alias="local")
client, collector = _make_client(storage)
+232
View File
@@ -0,0 +1,232 @@
"""Orphan-conversation scan + purge (the ``turnstone-admin orphan-conversations`` verb).
Orphans are conversation rows whose ``workstreams`` row is gone written by
historical unregistered paths or by the delete-during-inflight race (a late
tool-result save re-creating rows after ``delete_workstream``).
"""
from __future__ import annotations
import argparse
import hashlib
import pytest
from turnstone.admin import _cmd_orphan_conversations
def _orphan(backend, ws_id: str, n: int = 2) -> None:
"""Persist *n* conversation rows for *ws_id* WITHOUT registering it."""
for i in range(n):
backend.save_message(ws_id, "user" if i % 2 == 0 else "assistant", f"m{i}")
def _blob(backend, payload: bytes, origin: str = "upload") -> str:
"""Save a content-addressed attachment; each save bumps the refcount."""
aid = hashlib.sha256(payload).hexdigest()
backend.save_attachment(aid, "f.txt", "text/plain", len(payload), "text", payload, origin)
return aid
class TestOrphanScan:
def test_clean_db_has_no_orphans(self, backend):
backend.register_workstream("live1")
backend.save_message("live1", "user", "hello")
assert backend.list_orphan_conversations() == []
def test_orphan_reported_with_stats(self, backend):
_orphan(backend, "ghost1", n=3)
scan = backend.list_orphan_conversations()
assert len(scan) == 1
entry = scan[0]
assert entry["ws_id"] == "ghost1"
assert entry["rows"] == 3
assert entry["first"] <= entry["last"]
assert entry["attachment_refs"] == 0
def test_scan_counts_attachment_refs(self, backend):
_orphan(backend, "ghost2", n=1)
msg_id = backend.save_message("ghost2", "user", "with attachment")
aid = _blob(backend, b"orphan-bytes")
backend.set_message_attachments("ghost2", msg_id, [aid])
scan = backend.list_orphan_conversations()
assert scan[0]["attachment_refs"] == 1
def test_scan_is_oldest_first(self, backend):
_orphan(backend, "newer")
_orphan(backend, "older")
# Timestamps are insertion-ordered ISO text; rewrite to force ordering.
import sqlalchemy as sa
from turnstone.core.storage._schema import conversations
with backend._conn() as conn:
conn.execute(
sa.update(conversations)
.where(conversations.c.ws_id == "older")
.values(timestamp="2020-01-01T00:00:00")
)
conn.commit()
scan = backend.list_orphan_conversations()
assert [o["ws_id"] for o in scan] == ["older", "newer"]
class TestOrphanPurge:
def test_purge_deletes_only_orphans(self, backend):
backend.register_workstream("live1")
backend.save_message("live1", "user", "keep me")
_orphan(backend, "ghost1", n=4)
result = backend.delete_orphan_conversations(["ghost1"])
assert result == {"workstreams": 1, "rows": 4, "released_refs": 0, "skipped": 0}
assert backend.list_orphan_conversations() == []
assert len(backend.load_messages("live1")) == 1
def test_purge_skips_reregistered_ws(self, backend):
"""A ws_id that gained a workstreams row between scan and purge survives."""
_orphan(backend, "ghost1", n=2)
scan = [o["ws_id"] for o in backend.list_orphan_conversations()]
backend.register_workstream("ghost1")
result = backend.delete_orphan_conversations(scan)
assert result["skipped"] == 1
assert result["workstreams"] == 0
assert result["rows"] == 0
assert len(backend.load_messages("ghost1")) == 2
def test_purge_releases_refcounts_and_prunes_at_zero(self, backend):
_orphan(backend, "ghost1", n=1)
msg_id = backend.save_message("ghost1", "user", "img")
aid = _blob(backend, b"only-orphan-referenced")
backend.set_message_attachments("ghost1", msg_id, [aid])
result = backend.delete_orphan_conversations(["ghost1"])
assert result["released_refs"] == 1
assert backend.get_attachment(aid) is None
def test_purge_keeps_blob_shared_with_live_ws(self, backend):
payload = b"shared-bytes"
backend.register_workstream("live1")
live_msg = backend.save_message("live1", "user", "live ref")
aid_live = _blob(backend, payload)
backend.set_message_attachments("live1", live_msg, [aid_live])
_orphan(backend, "ghost1", n=1)
ghost_msg = backend.save_message("ghost1", "user", "ghost ref")
aid_ghost = _blob(backend, payload) # same content hash; refcount -> 2
backend.set_message_attachments("ghost1", ghost_msg, [aid_ghost])
assert aid_live == aid_ghost
result = backend.delete_orphan_conversations(["ghost1"])
assert result["released_refs"] == 1
row = backend.get_attachment(aid_live)
assert row is not None
assert row["refcount"] == 1
def test_purge_sweeps_config_rows(self, backend):
_orphan(backend, "ghost1", n=1)
backend.save_workstream_config("ghost1", {"model": "x"})
backend.delete_orphan_conversations(["ghost1"])
import sqlalchemy as sa
from turnstone.core.storage._schema import workstream_config
with backend._conn() as conn:
left = conn.execute(
sa.select(sa.func.count()).where(workstream_config.c.ws_id == "ghost1")
).scalar()
assert left == 0
def test_purge_dedupes_input(self, backend):
"""Duplicate ws_ids must not inflate counts or bind params."""
_orphan(backend, "ghost1", n=2)
result = backend.delete_orphan_conversations(["ghost1", "ghost1", "ghost1"])
assert result["workstreams"] == 1
assert result["rows"] == 2
assert result["skipped"] == 0
def test_purge_unknown_ws_counts_skipped(self, backend):
"""An input with no rows and no workstream is reported, not purged."""
result = backend.delete_orphan_conversations(["nope-never-existed"])
assert result == {"workstreams": 0, "rows": 0, "released_refs": 0, "skipped": 1}
def test_purge_chunks_large_input(self, backend, monkeypatch):
"""IN-lists are chunked (SQLite bind-parameter limits) without losing rows."""
import turnstone.core.storage._utils as storage_utils
monkeypatch.setattr(storage_utils, "_PURGE_CHUNK", 2)
for i in range(5):
_orphan(backend, f"ghost{i}", n=1)
result = backend.delete_orphan_conversations([f"ghost{i}" for i in range(5)])
assert result["workstreams"] == 5
assert result["rows"] == 5
assert backend.list_orphan_conversations() == []
def test_purge_empty_list_is_noop(self, backend):
assert backend.delete_orphan_conversations([]) == {
"workstreams": 0,
"rows": 0,
"released_refs": 0,
"skipped": 0,
}
class TestAdminVerb:
"""The CLI handler over a real (ephemeral) backend."""
def _args(self, **kw) -> argparse.Namespace:
return argparse.Namespace(delete=False, yes=False, **kw)
def test_scan_reports_and_does_not_delete(self, backend, monkeypatch, capsys):
_orphan(backend, "ghost1", n=2)
monkeypatch.setattr("turnstone.admin._get_storage", lambda args: backend)
_cmd_orphan_conversations(self._args())
out = capsys.readouterr().out
assert "ghost1" in out
assert "--delete" in out
assert len(backend.load_messages("ghost1")) == 2
def test_delete_yes_purges(self, backend, monkeypatch, capsys):
_orphan(backend, "ghost1", n=2)
monkeypatch.setattr("turnstone.admin._get_storage", lambda args: backend)
ns = self._args()
ns.delete = True
ns.yes = True
_cmd_orphan_conversations(ns)
out = capsys.readouterr().out
assert "Purged 2" in out
assert backend.list_orphan_conversations() == []
def test_delete_confirmation_abort(self, backend, monkeypatch, capsys):
_orphan(backend, "ghost1", n=1)
monkeypatch.setattr("turnstone.admin._get_storage", lambda args: backend)
monkeypatch.setattr("builtins.input", lambda prompt: "n")
ns = self._args()
ns.delete = True
with pytest.raises(SystemExit):
_cmd_orphan_conversations(ns)
assert len(backend.load_messages("ghost1")) == 1
def test_delete_summary_reports_partial_skip(self, backend, monkeypatch, capsys):
"""Mixed batch: summary shows ACTUAL purge counts plus the skipped clause."""
_orphan(backend, "ghost1", n=2)
_orphan(backend, "ghost2", n=3)
real_list = backend.list_orphan_conversations
def list_then_register():
scan = real_list()
backend.register_workstream("ghost2") # wins the scan-to-purge race
return scan
monkeypatch.setattr(backend, "list_orphan_conversations", list_then_register)
monkeypatch.setattr("turnstone.admin._get_storage", lambda args: backend)
ns = self._args()
ns.delete = True
ns.yes = True
_cmd_orphan_conversations(ns)
out = capsys.readouterr().out
assert "Purged 2 row(s) across 1 workstream(s)" in out
assert "skipped 1 re-registered" in out
assert len(backend.load_messages("ghost2")) == 3
def test_clean_db_message(self, backend, monkeypatch, capsys):
monkeypatch.setattr("turnstone.admin._get_storage", lambda args: backend)
_cmd_orphan_conversations(self._args())
assert "No orphan conversation rows." in capsys.readouterr().out
+357
View File
@@ -0,0 +1,357 @@
"""Tests for the ``anthropic-compatible`` provider lane.
Local servers (vLLM) expose Anthropic's ``/v1/messages`` wire surface for
arbitrary checkpoints. The lane reuses ``AnthropicProvider`` with
``compat=True``: identical message translation, but capabilities come from
``_ANTHROPIC_COMPAT_DEFAULT`` for every model (the static Claude table
never applies), native server-side tools are not injected, and operator
``server_compat["extra_body"]`` overrides ride the Anthropic SDK's
``extra_body`` the channel for vLLM's ``chat_template_kwargs`` reasoning
toggle.
"""
from __future__ import annotations
import os
import sys
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from tests._session_helpers import make_session as _make_session
from turnstone.core.providers._anthropic import AnthropicProvider
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _capture_client() -> MagicMock:
"""Build a fake Anthropic client whose ``messages.stream`` records kwargs."""
stream_ctx = MagicMock()
stream_ctx.__enter__ = MagicMock(return_value=iter([]))
stream_ctx.__exit__ = MagicMock(return_value=False)
client = MagicMock()
client.messages.stream.return_value = stream_ctx
return client
_WEB_SEARCH_FUNCTION_TOOL = {
"type": "function",
"function": {
"name": "web_search",
"description": "Search the web",
"parameters": {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"],
},
},
}
# ===========================================================================
# TestCompatCapabilities
# ===========================================================================
class TestCompatCapabilities:
"""Capability resolution on the compat lane."""
def test_compat_capability_defaults(self) -> None:
provider = AnthropicProvider(compat=True)
caps = provider.get_capabilities("deepseek-ai/DeepSeek-V4-Flash")
assert caps.token_param == "max_tokens"
assert caps.thinking_mode == "none"
assert caps.supports_web_search is False
assert caps.supports_tool_search is False
assert caps.supports_vision is False
assert caps.supports_reasoning_replay is True
assert caps.supports_temperature is True
def test_claude_id_does_not_pick_up_static_table(self) -> None:
"""A Claude-named local checkpoint must not inherit Claude API caps."""
provider = AnthropicProvider(compat=True)
caps = provider.get_capabilities("claude-opus-4-6")
assert caps.context_window == 200000
assert caps.thinking_mode == "none"
assert caps.supports_web_search is False
# The real lane still resolves the static entry.
real_caps = AnthropicProvider().get_capabilities("claude-opus-4-6")
assert real_caps.context_window == 1000000
assert real_caps.thinking_mode == "adaptive"
# ===========================================================================
# TestCompatWireShape
# ===========================================================================
class TestCompatWireShape:
"""Body-inspecting tests on the kwargs handed to ``messages.stream``."""
def setup_method(self) -> None:
self.provider = AnthropicProvider(compat=True)
@patch("turnstone.core.providers._anthropic._ensure_anthropic")
def test_compat_no_web_search_swap_no_temp_force(self, mock_ensure: MagicMock) -> None:
"""No native web_search swap, no temperature=1 forcing, max_tokens param."""
client = _capture_client()
list(
self.provider.create_streaming(
client=client,
model="deepseek-ai/DeepSeek-V4-Flash",
messages=[{"role": "user", "content": "hi"}],
tools=[_WEB_SEARCH_FUNCTION_TOOL],
temperature=0.6,
)
)
kwargs = client.messages.stream.call_args[1]
sent_tools = kwargs["tools"]
assert sent_tools == [
{
"name": "web_search",
"description": "Search the web",
"input_schema": {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"],
},
}
]
assert all(t.get("type") != "web_search_20250305" for t in sent_tools)
assert kwargs["temperature"] == 0.6
assert "thinking" not in kwargs
assert "max_tokens" in kwargs
assert "max_completion_tokens" not in kwargs
@patch("turnstone.core.providers._anthropic._ensure_anthropic")
def test_extra_params_passthrough_to_extra_body(self, mock_ensure: MagicMock) -> None:
"""server_compat extra_body (chat_template_kwargs) reaches the SDK."""
client = _capture_client()
list(
self.provider.create_streaming(
client=client,
model="deepseek-ai/DeepSeek-V4-Flash",
messages=[{"role": "user", "content": "hi"}],
extra_params={"chat_template_kwargs": {"thinking": False}},
)
)
kwargs = client.messages.stream.call_args[1]
assert kwargs["extra_body"] == {"chat_template_kwargs": {"thinking": False}}
@patch("turnstone.core.providers._anthropic._ensure_anthropic")
def test_internal_keys_not_leaked(self, mock_ensure: MagicMock) -> None:
"""Real-lane request bodies stay byte-identical with thinking overrides.
``thinking_budget_tokens`` is consumed by ``_reasoning_params`` and
must never surface as wire ``extra_body`` a leaked key would change
every real-Anthropic request that threads a thinking override.
Negative-tested: fails when the ``_INTERNAL_EXTRA_PARAMS`` exclusion
is removed from ``_build_thinking_and_kwargs``.
"""
provider = AnthropicProvider()
client = _capture_client()
list(
provider.create_streaming(
client=client,
model="claude-sonnet-4-5",
messages=[{"role": "user", "content": "hi"}],
extra_params={"thinking_budget_tokens": 2048},
)
)
kwargs = client.messages.stream.call_args[1]
assert "extra_body" not in kwargs
assert kwargs["thinking"] == {"type": "enabled", "budget_tokens": 2048}
# ===========================================================================
# TestCompatFactory
# ===========================================================================
class TestCompatFactory:
"""create_provider / create_client routing for the compat lane."""
def test_create_provider_anthropic_compatible(self) -> None:
from turnstone.core.providers import create_provider
provider = create_provider("anthropic-compatible")
assert provider.provider_name == "anthropic-compatible"
assert provider is not create_provider("anthropic")
assert create_provider("anthropic-compatible") is provider
@patch("turnstone.core.providers._anthropic._ensure_anthropic")
def test_create_client_anthropic_compatible(self, mock_ensure: MagicMock) -> None:
"""base_url forwards verbatim; empty api_key is omitted entirely."""
from turnstone.core.providers import create_client
mock_anthropic_cls = MagicMock()
mock_mod = MagicMock()
mock_mod.Anthropic = mock_anthropic_cls
mock_ensure.return_value = mock_mod
create_client("anthropic-compatible", base_url="http://vllm-host:8000", api_key="")
mock_anthropic_cls.assert_called_once_with(base_url="http://vllm-host:8000")
@patch("turnstone.core.providers._anthropic._ensure_anthropic")
def test_create_client_strips_v1_suffix(self, mock_ensure: MagicMock) -> None:
"""A /v1-suffixed base_url (openai-compatible muscle memory) is
normalized for the compat lane the SDK appends /v1/... itself,
so the verbatim URL would request /v1/v1/messages and 404."""
from turnstone.core.providers import create_client
mock_anthropic_cls = MagicMock()
mock_mod = MagicMock()
mock_mod.Anthropic = mock_anthropic_cls
mock_ensure.return_value = mock_mod
for suffixed in ("http://vllm-host:8000/v1", "http://vllm-host:8000/v1/"):
mock_anthropic_cls.reset_mock()
create_client("anthropic-compatible", base_url=suffixed, api_key="")
mock_anthropic_cls.assert_called_once_with(base_url="http://vllm-host:8000")
# A base_url that strips to nothing stays verbatim so the typo
# fails loudly in httpx instead of silently targeting the SDK's
# prod default.
mock_anthropic_cls.reset_mock()
create_client("anthropic-compatible", base_url="/v1", api_key="")
mock_anthropic_cls.assert_called_once_with(base_url="/v1")
@patch("turnstone.core.providers._anthropic._ensure_anthropic")
def test_create_client_requires_base_url(self, mock_ensure: MagicMock) -> None:
"""Empty base_url fails at construction — the local-only lane must
never fall back to the SDK's https://api.anthropic.com default."""
from turnstone.core.providers import create_client
with pytest.raises(ValueError, match="anthropic-compatible requires base_url"):
create_client("anthropic-compatible", base_url="", api_key="dummy")
mock_ensure.return_value.Anthropic.assert_not_called()
@patch("turnstone.core.providers._anthropic._ensure_anthropic")
def test_create_client_real_lane_base_url_untouched(self, mock_ensure: MagicMock) -> None:
"""The real anthropic lane forwards base_url verbatim — the /v1
normalization is compat-lane-only."""
from turnstone.core.providers import create_client
mock_anthropic_cls = MagicMock()
mock_mod = MagicMock()
mock_mod.Anthropic = mock_anthropic_cls
mock_ensure.return_value = mock_mod
create_client("anthropic", base_url="http://proxy:9000/v1", api_key="k")
mock_anthropic_cls.assert_called_once_with(api_key="k", base_url="http://proxy:9000/v1")
# ===========================================================================
# TestCliScope
# ===========================================================================
class TestCliScope:
def test_cli_rejects_compat_provider_id(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""The lane is registry-only — the CLI --provider flag does not grow."""
from turnstone import cli
monkeypatch.setattr(sys, "argv", ["turnstone", "--provider", "anthropic-compatible"])
with pytest.raises(SystemExit) as excinfo:
cli.main()
assert excinfo.value.code == 2
# ===========================================================================
# TestCompatSessionPlumbing
# ===========================================================================
class TestCompatSessionPlumbing:
"""ChatSession capability merge + extra_params gate for the lane."""
def test_per_model_capability_override_merge(self, tmp_db: Any) -> None:
"""Per-model capabilities win over _ANTHROPIC_COMPAT_DEFAULT fields."""
from turnstone.core.model_registry import ModelConfig, ModelRegistry
from turnstone.core.providers import create_provider
cfg = ModelConfig(
alias="vllm-messages",
base_url="http://localhost:8000",
api_key="dummy",
model="deepseek-ai/DeepSeek-V4-Flash",
provider="anthropic-compatible",
capabilities={"supports_mid_conversation_system": True, "context_window": 131072},
)
registry = ModelRegistry(models={"vllm-messages": cfg}, default="vllm-messages")
session = _make_session(registry=registry, model_alias="vllm-messages")
provider = create_provider("anthropic-compatible")
caps = session._resolve_capabilities(
provider, "deepseek-ai/DeepSeek-V4-Flash", "vllm-messages"
)
assert caps.supports_mid_conversation_system is True
assert caps.context_window == 131072
# Untouched fields keep the compat-lane defaults.
assert caps.token_param == "max_tokens"
assert caps.thinking_mode == "none"
assert caps.supports_web_search is False
assert caps.supports_vision is False
def test_session_extra_params_gate(self, tmp_db: Any) -> None:
"""server_compat extra_body forwards for the compat lane, not real Anthropic."""
from turnstone.core.model_registry import ModelConfig, ModelRegistry
from turnstone.core.providers import create_provider
session = _make_session(reasoning_effort="medium")
cfg = ModelConfig(
alias="vllm-messages",
base_url="http://localhost:8000",
api_key="dummy",
model="deepseek-ai/DeepSeek-V4-Flash",
provider="anthropic-compatible",
server_compat={"extra_body": {"chat_template_kwargs": {"thinking": False}}},
)
session._registry = ModelRegistry(models={"vllm-messages": cfg}, default="vllm-messages")
session._model_alias = "vllm-messages"
session._provider = create_provider("anthropic-compatible")
assert session._provider_extra_params() == {"chat_template_kwargs": {"thinking": False}}
session._provider = create_provider("anthropic")
assert session._provider_extra_params() is None
# ===========================================================================
# TestLiveCompatStream
# ===========================================================================
@pytest.mark.live
@pytest.mark.skipif(
not os.environ.get("TURNSTONE_LIVE_ANTHROPIC_COMPAT_URL"),
reason="TURNSTONE_LIVE_ANTHROPIC_COMPAT_URL not set",
)
class TestLiveCompatStream:
"""One real streamed turn against a vLLM /v1/messages endpoint."""
def test_live_compat_streamed_turn(self) -> None:
from turnstone.core.providers import create_client, create_provider
base_url = os.environ["TURNSTONE_LIVE_ANTHROPIC_COMPAT_URL"]
model = os.environ.get(
"TURNSTONE_LIVE_ANTHROPIC_COMPAT_MODEL", "deepseek-ai/DeepSeek-V4-Flash"
)
client = create_client("anthropic-compatible", base_url=base_url, api_key="dummy")
provider = create_provider("anthropic-compatible")
chunks = list(
provider.create_streaming(
client=client,
model=model,
messages=[{"role": "user", "content": "Reply with the single word: pong"}],
max_tokens=64,
extra_params={"chat_template_kwargs": {"thinking": False}},
)
)
content = "".join(c.content_delta or "" for c in chunks)
assert content.strip()
assert any(c.finish_reason for c in chunks)
assert any(c.usage is not None for c in chunks)
assert not any(c.reasoning_delta for c in chunks)
+105
View File
@@ -21,6 +21,7 @@ from turnstone.console.server import (
admin_get_schedule,
admin_list_schedule_runs,
admin_list_schedules,
admin_preview_schedule,
admin_update_schedule,
)
from turnstone.core.auth import AuthResult
@@ -54,6 +55,11 @@ def client(storage):
routes=[
Route("/api/admin/schedules", admin_list_schedules),
Route("/api/admin/schedules", admin_create_schedule, methods=["POST"]),
Route(
"/api/admin/schedules/preview",
admin_preview_schedule,
methods=["POST"],
),
Route("/api/admin/schedules/{task_id}", admin_get_schedule),
Route(
"/api/admin/schedules/{task_id}",
@@ -283,3 +289,102 @@ class TestScheduleAPI:
resp = client.get(f"/v1/api/admin/schedules/{task_id}/runs?limit=abc")
assert resp.status_code == 200
assert resp.json()["runs"] == []
class TestPreviewSchedule:
"""POST /v1/api/admin/schedules/preview — the editor's NEXT RUNS read-out."""
def test_valid_cron_returns_three_ascending_runs(self, client):
resp = client.post(
"/v1/api/admin/schedules/preview",
json={"schedule_type": "cron", "cron_expr": "0 6 * * *"},
)
assert resp.status_code == 200
data = resp.json()
assert data["valid"] is True
assert data["error"] == ""
assert len(data["next"]) == 3
assert data["next"] == sorted(data["next"])
# All at 06:00 (the daily expression's only firing time), in the
# uniform offset-bearing shape the 'at' branch also uses
assert all(t.endswith("T06:00:00+00:00") for t in data["next"])
def test_invalid_cron_is_a_200_with_the_message(self, client):
resp = client.post(
"/v1/api/admin/schedules/preview",
json={"schedule_type": "cron", "cron_expr": "not a cron"},
)
assert resp.status_code == 200
data = resp.json()
assert data["valid"] is False
assert "Invalid cron expression" in data["error"]
assert data["next"] == []
def test_missing_cron_expr(self, client):
resp = client.post(
"/v1/api/admin/schedules/preview",
json={"schedule_type": "cron", "cron_expr": ""},
)
data = resp.json()
assert data["valid"] is False
assert "cron_expr is required" in data["error"]
def test_at_future_echoes_the_time(self, client):
resp = client.post(
"/v1/api/admin/schedules/preview",
json={"schedule_type": "at", "at_time": "2030-01-01T12:00:00+00:00"},
)
data = resp.json()
assert data["valid"] is True
assert data["next"] == ["2030-01-01T12:00:00+00:00"]
def test_at_in_the_past_is_invalid(self, client):
resp = client.post(
"/v1/api/admin/schedules/preview",
json={"schedule_type": "at", "at_time": "2020-01-01T12:00:00+00:00"},
)
data = resp.json()
assert data["valid"] is False
assert "future" in data["error"]
def test_unknown_schedule_type(self, client):
resp = client.post(
"/v1/api/admin/schedules/preview",
json={"schedule_type": "sometimes"},
)
data = resp.json()
assert data["valid"] is False
assert "schedule_type" in data["error"]
def test_impossible_calendar_date_cron_is_a_200_not_a_500(self, client):
"""croniter.is_valid passes '0 0 30 2 *' (Feb 30) but get_next raises
CroniterBadDateError the preview must answer its 200/valid:false
contract, not crash."""
resp = client.post(
"/v1/api/admin/schedules/preview",
json={"schedule_type": "cron", "cron_expr": "0 0 30 2 *"},
)
assert resp.status_code == 200
data = resp.json()
assert data["valid"] is False
assert "calendar" in data["error"]
assert data["next"] == []
def test_create_with_impossible_date_cron_does_not_500(self, client):
"""_compute_next_run shares the guard: creating such a schedule must
not crash (next_run computes as empty)."""
resp = client.post(
"/v1/api/admin/schedules",
json=_cron_payload(cron_expr="0 0 31 4 *"),
)
assert resp.status_code == 200
assert resp.json()["next_run"] == ""
def test_cron_next_runs_carry_a_utc_offset(self, client):
"""next[] must be one shape: the 'at' branch echoes offset-bearing
ISO, so the cron branch appends the UTC offset too."""
resp = client.post(
"/v1/api/admin/schedules/preview",
json={"schedule_type": "cron", "cron_expr": "0 6 * * *"},
)
assert all(t.endswith("+00:00") for t in resp.json()["next"])
+278 -5
View File
@@ -5,6 +5,7 @@ import contextlib
import json
import subprocess
import time
from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock, patch
@@ -456,12 +457,15 @@ class TestTaskExec:
def test_evaluate_intent_drops_superseded_generation_verdict(self, tmp_db, monkeypatch) -> None:
"""A prior turn's judge daemon (still running because
cancel_on_approval defaults False) must NOT deliver verdicts once a
newer turn has superseded it otherwise a model that reuses a
call_id across turns could ride a stale ``approve`` into a wrongful
Smart Approval of a different call."""
cancel_on_approval defaults False) must NOT deliver verdicts to the
live surfaces once a newer turn has superseded it otherwise a model
that reuses a call_id across turns could ride a stale ``approve``
into a wrongful Smart Approval of a different call. The superseded
verdict is NOT lost, though: it routes to the persist-only audit
hook so ``intent_verdicts`` still records the judge's ruling."""
session = _make_session()
session.ui.on_intent_verdict = MagicMock()
session.ui.on_superseded_intent_verdict = MagicMock()
fake_verdict = MagicMock()
fake_verdict.to_dict.return_value = {"verdict_id": "v0", "call_id": "c1", "tier": "llm"}
captured: list[Any] = []
@@ -476,13 +480,107 @@ class TestTaskExec:
session._evaluate_intent([dict(item)]) # generation B supersedes A
callback_a, callback_b = captured[0], captured[1]
# A's late verdict (the superseded daemon) is dropped.
# A's late verdict: withheld from the live surfaces, persisted for audit.
callback_a(fake_verdict)
session.ui.on_intent_verdict.assert_not_called()
session.ui.on_superseded_intent_verdict.assert_called_once_with(
{"verdict_id": "v0", "call_id": "c1", "tier": "llm"}
)
# B's verdict (the current generation) is delivered normally.
callback_b(fake_verdict)
session.ui.on_intent_verdict.assert_called_once()
session.ui.on_superseded_intent_verdict.assert_called_once() # unchanged
def test_superseded_verdict_skips_persist_on_display_only_ui(self, tmp_db, monkeypatch) -> None:
"""Display-only UIs (CLI / eval) don't define the persist-only hook;
the superseded path must degrade to a plain drop, not raise."""
session = _make_session()
session.ui = SimpleNamespace(on_intent_verdict=MagicMock()) # no superseded hook
fake_verdict = MagicMock()
fake_verdict.to_dict.return_value = {"verdict_id": "v0", "call_id": "c1", "tier": "llm"}
captured: list[Any] = []
fake_judge = MagicMock()
fake_judge.evaluate.side_effect = lambda items, *_a, **kw: (
captured.append(kw.get("callback")) or [fake_verdict] * len(items)
)
monkeypatch.setattr(session, "_ensure_judge", lambda: fake_judge)
item = {"call_id": "c1", "func_name": "bash", "needs_approval": True, "command": "ls"}
session._evaluate_intent([dict(item)]) # generation A
session._evaluate_intent([dict(item)]) # generation B supersedes A
captured[0](fake_verdict) # must not raise
session.ui.on_intent_verdict.assert_not_called()
def _drive_gate(self, session, monkeypatch, *, cancel_on_approval: bool):
"""Run one needs_approval bash item through ``_execute_tools`` with a
stubbed judge + approval gate; return the cancel event the judge
daemon would be watching."""
from unittest.mock import PropertyMock
from turnstone.core.judge import JudgeConfig
captured: dict[str, Any] = {}
fake_verdict = MagicMock()
fake_verdict.to_dict.return_value = {
"verdict_id": "v0",
"call_id": "c1",
"tier": "heuristic",
}
fake_judge = MagicMock()
def _eval(items, *_a, **kw):
captured["event"] = kw.get("cancel_event")
return [fake_verdict] * len(items)
fake_judge.evaluate.side_effect = _eval
monkeypatch.setattr(session, "_ensure_judge", lambda: fake_judge)
cfg = JudgeConfig(enabled=True, cancel_on_approval=cancel_on_approval)
item = {
"call_id": "c1",
"func_name": "bash",
"needs_approval": True,
"command": "ls",
"execute": lambda _it: "ok",
}
with (
patch.object(type(session), "_judge_cfg", new_callable=PropertyMock, return_value=cfg),
patch.object(session, "_safe_prepare_tool", return_value=item),
patch.object(session.ui, "approve_tools", return_value=(True, None)),
):
session._execute_tools(
[{"id": "c1", "type": "function", "function": {"name": "bash", "arguments": "{}"}}]
)
return captured["event"]
def test_gate_resolution_keeps_judge_running_by_default(self, tmp_db, monkeypatch) -> None:
"""cancel_on_approval=False (the default): resolving the approval
gate must NOT fire the judge's abort signal — the daemon runs every
item to completion so each call lands a real LLM verdict, exactly
what the setting's help text promises. An unconditional set in the
gate's ``finally`` used to degrade every still-queued item to a
llm_fallback row the instant the operator approved."""
session = _make_session()
event = self._drive_gate(session, monkeypatch, cancel_on_approval=False)
assert event is not None
assert not event.is_set()
# The supersede path still aborts unconditionally: the next batch
# fires the previous generation's event before spawning its own.
session._judge_cancel_event = event
self._drive_gate(session, monkeypatch, cancel_on_approval=False)
assert event.is_set()
def test_gate_resolution_cancels_judge_when_opted_in(self, tmp_db, monkeypatch) -> None:
"""cancel_on_approval=True: the gate's ``finally`` fires the abort
signal as soon as the approval resolves, trading verdict
completeness for inference savings."""
session = _make_session()
event = self._drive_gate(session, monkeypatch, cancel_on_approval=True)
assert event is not None
assert event.is_set()
# ---------------------------------------------------------------------------
@@ -2815,6 +2913,181 @@ class TestMemoryCompositionDeferral:
assert session._system_composed_with_context is False
class TestMemoryAccessTouch:
"""Access metadata (``access_count`` / ``last_accessed``) moves only when
the model actually sees a memory: the injected top-k during composition,
and explicit search/get reads via the memory tool. Save/list and the
wider candidate pool must NOT bump the counter.
"""
@staticmethod
def _access_count(name: str, scope: str = "global", scope_id: str = "") -> int:
from turnstone.core.storage import get_storage
mem = get_storage().get_structured_memory_by_name(name, scope, scope_id)
assert mem is not None, f"memory {name!r} not found"
return int(mem["access_count"])
@staticmethod
def _save(name: str, content: str) -> None:
from turnstone.core.memory import save_structured_memory
save_structured_memory(name, content, scope="global")
@staticmethod
def _empty_session() -> ChatSession:
"""A session whose __init__ composed before any memory existed.
The constructor composes the system prefix once; building it before
the memories are saved keeps that first (empty) compose from touching
rows, so the tests observe only the turn-driven recompose below.
"""
return _make_session(ws_id="ws-1", user_id="user-1")
@staticmethod
def _compose_turn(session: ChatSession, query: str) -> None:
"""Drive one user turn's worth of composition.
Mirrors ``send``: a fresh user turn invalidates the per-turn memory
caches, then the prefix recomposes against the new query.
"""
session._invalidate_memory_cache()
session.messages.append(turn_from_dict({"role": "user", "content": query}))
session._init_system_messages()
def test_composition_touches_injected_memories(self, tmp_db):
session = self._empty_session()
self._save("kafka_runbook", "restart the kafka broker pods")
self._save("kafka_alerts", "kafka consumer lag alert thresholds")
self._compose_turn(session, "how do I restart kafka")
# Both query-matching memories were injected, so both got touched once.
assert self._access_count("kafka_runbook") == 1
assert self._access_count("kafka_alerts") == 1
def test_composition_skips_unmatched_candidates(self, tmp_db):
"""The candidate pool is a superset of the injected set — a memory
that loses BM25 ranking (no query overlap) must NOT be touched."""
session = self._empty_session()
self._save("kafka_runbook", "restart the kafka broker pods")
self._save("garden_notes", "tomato watering schedule midsummer")
self._compose_turn(session, "restart kafka broker pods status")
# The matching memory was injected and touched.
assert self._access_count("kafka_runbook") == 1
# The non-matching one was a candidate but never injected.
assert self._access_count("garden_notes") == 0
# Sanity: it really was in the visible candidate pool.
visible = {m["name"] for m in session._list_visible_memories()}
assert "garden_notes" in visible
def test_composition_touches_each_memory_once_per_turn(self, tmp_db):
"""``_init_system_messages`` runs many times within a turn (tool
results, MCP refresh); the injected set must be touched at most once
per memory between user turns, not once per recompose."""
session = self._empty_session()
self._save("kafka_runbook", "restart the kafka broker pods")
self._compose_turn(session, "how do I restart kafka")
# Several mid-turn recomposes (no new user turn between them).
session._init_system_messages()
session._init_system_messages()
assert self._access_count("kafka_runbook") == 1
# A genuinely new turn lets the same memory be counted again.
self._compose_turn(session, "kafka again please")
assert self._access_count("kafka_runbook") == 2
def test_composition_touches_exactly_the_injected_keys(self, tmp_db):
"""Spy the touch boundary and assert the keys match the names the
composer rendered into the ``<memories>`` block exactly, not the
candidate pool."""
session = self._empty_session()
self._save("kafka_runbook", "restart the kafka broker pods")
self._save("garden_notes", "tomato watering schedule midsummer")
session._invalidate_memory_cache()
session.messages.append(
turn_from_dict({"role": "user", "content": "restart kafka broker pods status"})
)
touched: list[tuple[str, str, str]] = []
with patch(
"turnstone.core.session.touch_structured_memories",
side_effect=lambda keys: touched.extend(keys),
):
session._init_system_messages()
joined = "\n".join(m["content"] for m in session.system_messages if m["role"] == "system")
touched_names = {name for name, _, _ in touched}
assert touched_names == {"kafka_runbook"}
assert '<memory name="kafka_runbook"' in joined
assert '<memory name="garden_notes"' not in joined
def test_composition_survives_touch_storage_error(self, tmp_db):
"""A storage blow-up inside the touch must not break composition —
the facade swallows it and the memory block still lands."""
from turnstone.core.storage import get_storage
session = self._empty_session()
self._save("kafka_runbook", "restart the kafka broker pods")
session._invalidate_memory_cache()
session.messages.append(
turn_from_dict({"role": "user", "content": "how do I restart kafka"})
)
with patch.object(
get_storage(),
"touch_structured_memories",
side_effect=RuntimeError("storage exploded"),
):
session._init_system_messages()
joined = "\n".join(m["content"] for m in session.system_messages if m["role"] == "system")
assert '<memory name="kafka_runbook"' in joined
def test_search_action_touches_returned_hits(self, tmp_db):
session = self._empty_session()
self._save("kafka_runbook", "restart the kafka broker pods")
item = session._prepare_memory("call_1", {"action": "search", "query": "kafka"})
assert "error" not in item
session._exec_memory(item)
assert self._access_count("kafka_runbook") == 1
def test_get_action_touches_fetched_memory(self, tmp_db):
session = self._empty_session()
self._save("kafka_runbook", "restart the kafka broker pods")
item = session._prepare_memory(
"call_1", {"action": "get", "name": "kafka_runbook", "scope": "global"}
)
assert "error" not in item
session._exec_memory(item)
assert self._access_count("kafka_runbook") == 1
def test_get_miss_touches_nothing(self, tmp_db):
session = self._empty_session()
self._save("kafka_runbook", "restart the kafka broker pods")
item = session._prepare_memory(
"call_1", {"action": "get", "name": "no_such_mem", "scope": "global"}
)
_, msg = session._exec_memory(item)
assert "not found" in msg
# The existing row must not be collaterally touched by a miss.
assert self._access_count("kafka_runbook") == 0
def test_list_action_does_not_touch(self, tmp_db):
session = self._empty_session()
self._save("kafka_runbook", "restart the kafka broker pods")
item = session._prepare_memory("call_1", {"action": "list"})
session._exec_memory(item)
assert self._access_count("kafka_runbook") == 0
def test_save_action_does_not_touch_access_count(self, tmp_db):
"""The save action handler itself must not bump ``access_count`` —
that counter is read traffic only. (The recompose a save triggers
may surface the row via the composition path; that is exercised by
the composition tests. Suppressed here to isolate the handler.)"""
session = self._empty_session()
item = session._prepare_memory(
"call_1",
{"action": "save", "name": "kafka_runbook", "content": "x", "scope": "global"},
)
with patch.object(session, "_init_system_messages"):
session._exec_memory(item)
assert self._access_count("kafka_runbook") == 0
class TestMetacognitiveBuffers:
"""Nudges drain through advisory channels, not the system message."""
+29
View File
@@ -173,6 +173,35 @@ def test_on_intent_verdict_stamps_immediately_when_decision_already_set() -> Non
storage.update_intent_verdict.assert_called_once_with("v-late", user_decision="approved")
def test_on_superseded_intent_verdict_persists_without_live_surfaces() -> None:
"""The persist-only audit hook for verdicts that landed after a newer
turn replaced their judge generation: the row reaches storage with
user_decision="superseded", but NONE of the live surfaces move no
SSE event, no ``_llm_verdicts`` cache entry (Smart Approvals must
never see a stale call_id), no ``_pending_verdicts`` park (the next
``resolve_approval`` must not stamp it with the wrong decision)."""
storage = MagicMock()
ui = _make_ui()
lq = ui._register_listener()
verdict = {
"verdict_id": "v-late",
"call_id": "c-late",
"func_name": "bash",
"risk_level": "low",
"tier": "llm",
}
with _patch_get_storage(storage):
ui.on_superseded_intent_verdict(verdict)
storage.upsert_intent_verdict.assert_called_once()
kwargs = storage.upsert_intent_verdict.call_args.kwargs
assert kwargs["verdict_id"] == "v-late"
assert kwargs["user_decision"] == "superseded"
assert lq.empty() # no SSE delivery
assert "c-late" not in ui._llm_verdicts # no replay-cache write
assert ui._pending_verdicts == [] # no decision-stamp park
assert "user_decision" not in verdict # caller's dict not mutated
def test_llm_verdict_cache_evicts_oldest_at_cap() -> None:
"""FIFO eviction at ``_LLM_VERDICT_CACHE_MAX`` prevents unbounded
growth on a long-running session."""
+59 -2
View File
@@ -330,6 +330,63 @@ def test_step3_rail_manage_builds_from_admin_seam() -> None:
assert "aria-expanded" in body, "collapsible group heads must expose aria-expanded"
def test_rail_manage_row_badge_hook() -> None:
"""rail.js owns a GENERIC Manage-row count badge — `setRowBadge(tabKey, count,
label?)` stamps a glyph+count chip on a tab row (DS warn `.rail-badge`), and so
a COLLAPSED group never hides the signal, mirrors the group's running total onto
its head. rail.js stays agnostic about what the count means (no consent
specifics here); a subsystem drives it. `mountManage` registers the row/head
refs and re-applies live counts across a (re)mount. This is the re-homing
target for the MCP consent badge after its settings-gear host was deleted."""
body = _RAIL_JS.read_text(encoding="utf-8")
assert "export function setRowBadge(tabKey, count, label)" in body, (
"rail must export the generic setRowBadge hook (mechanism, not meaning)"
)
# The chip pairs colour with a glyph (never colour alone — chip-contrast rule).
assert "rail-badge" in body and '""' in body, (
"the badge must carry a ⚠ glyph alongside the count (not colour alone)"
)
# The collapsed-group head must carry the group total so a hidden row's signal
# still surfaces — pin the head propagation + the per-group sum.
assert "function _groupCount(" in body, "the head badge must sum the group's tab counts"
assert "_groupEls" in body and "_rowEls" in body, (
"mountManage must register row + owning-group-head refs for the badge hook"
)
assert "_reapplyBadges()" in body, (
"a (re)mount must re-apply any live badge state (the refs are rebuilt)"
)
# rail.js stays agnostic — the hook takes a generic tabKey/count, with no
# consent-specific endpoint, fetch, or branch (an explanatory comment naming a
# sample caller is fine; logic is not). `setRowBadge` itself never fetches.
badge_fn = body[body.index("export function setRowBadge") :]
badge_fn = badge_fn[: badge_fn.index("\n}\n") + 3]
assert "fetch" not in badge_fn and "/v1/" not in badge_fn, (
"the generic badge hook must not reach into a subsystem (no fetch / endpoint)"
)
# No colour-only treatment: the chip uses DS warn tokens AND a glyph.
css = _SHELL_CSS.read_text(encoding="utf-8")
assert ".rail-badge" in css, "shell.css must carry the .rail-badge chip rule"
badge_block = css[css.index(".rail-badge") :]
badge_block = badge_block[: badge_block.index("\n.grp") if "\n.grp" in badge_block else 800]
assert "var(--warn" in badge_block, (
"the badge chip must use the DS --warn family (theme-flips by construction)"
)
assert "#" not in badge_block, "the badge chip must be token-only (no hex) so themes flip"
def test_shell_bridges_setrowbadge_for_classic_subsystems() -> None:
"""shell.js is the ESM module bridge: a classic-script subsystem (the
standalone consent badge in ui/static/app.js) can't import rail.js, so the
shell re-exports `setRowBadge` on the `window.TS_SHELL` seam. Pin the import
and the seam so the bridge can't be silently dropped (which would re-break the
badge the same way the gear deletion did)."""
body = _SHELL_JS.read_text(encoding="utf-8")
assert 'setRowBadge } from "./rail.js"' in body, "shell must import setRowBadge from rail.js"
assert "notifySessionClosed, setRowBadge }" in body, (
"TS_SHELL must expose setRowBadge for classic subsystems (the consent-badge bridge)"
)
def test_step3_admin_seam_and_thin_show_admin() -> None:
"""admin.js exposes the TS_ADMIN seam (IA + shared perm gate + active-tab +
openTab) and showAdmin is now a thin delegator that opens the singleton
@@ -615,7 +672,7 @@ def test_step7_live_tab_state_glyphs() -> None:
)
assert "this.stateful" in pane, "ShellPane must carry the stateful flag"
shell = _SHELL_JS.read_text(encoding="utf-8")
assert 'import { mountRail, mountManage, glyph } from "./rail.js"' in shell, (
assert 'import { mountRail, mountManage, glyph, setRowBadge } from "./rail.js"' in shell, (
"the shell must import the rail's glyph builder (one source for tab + rail)"
)
assert "function stateForWs(" in shell and "function paintConvTabs(" in shell
@@ -749,7 +806,7 @@ def test_shell_marks_pane_dead_on_ws_closed() -> None:
assert "const notifySessionClosed = (wsId)" in shell
assert 'pm.getPane("interactive", wsId)' in shell
assert "p._ctl.markDead()" in shell
assert "window.TS_SHELL = { panes: pm, caps, notifySessionClosed }" in shell, (
assert "window.TS_SHELL = { panes: pm, caps, notifySessionClosed, setRowBadge }" in shell, (
"the seam must be exported on TS_SHELL for the console's Tier-1 handler"
)
app = _CONSOLE_APP.read_text(encoding="utf-8")
+197
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock
import pytest
@@ -86,3 +87,199 @@ def test_collector_tls_defaults():
collector = ClusterCollector(storage=storage_mock)
# Should store TLS settings for async client creation
assert collector._tls_verify is True
# ── init() retry ─────────────────────────────────────────────────────────────
def _make_flaky_client(monkeypatch, failures: int):
"""TLSClient whose CA fetch fails ``failures`` times, then succeeds.
Returns (client, calls, sleeps) mutable lists recording each CA-fetch
attempt and each backoff delay (asyncio.sleep is stubbed out).
"""
import asyncio
from turnstone.core.tls import TLSClient
client = TLSClient(
storage=get_storage(),
console_url="http://console:9999",
hostnames=["node-1"],
)
calls: list[int] = []
sleeps: list[float] = []
async def flaky_fetch():
calls.append(len(calls) + 1)
if len(calls) <= failures:
raise ConnectionError("console not accepting connections yet")
async def ok_request():
pass
async def fake_sleep(delay):
sleeps.append(delay)
monkeypatch.setattr(client, "_fetch_ca_cert", flaky_fetch)
monkeypatch.setattr(client, "_request_cert", ok_request)
monkeypatch.setattr(asyncio, "sleep", fake_sleep)
return client, calls, sleeps
@pytest.mark.anyio
async def test_init_rejects_invalid_retry_params():
"""attempts < 1 would make init() a silent no-op; fail fast instead."""
from turnstone.core.tls import TLSClient
client = TLSClient(
storage=get_storage(),
console_url="http://console:9999",
hostnames=["node-1"],
)
with pytest.raises(ValueError, match="attempts must be >= 1"):
await client.init(attempts=0)
with pytest.raises(ValueError, match="base_delay must be >= 0"):
await client.init(attempts=2, base_delay=-1.0)
@pytest.mark.anyio
async def test_init_default_single_attempt(monkeypatch):
"""Default init() keeps the old behavior: one attempt, no sleep."""
client, calls, sleeps = _make_flaky_client(monkeypatch, failures=1)
with pytest.raises(ConnectionError):
await client.init()
assert calls == [1]
assert sleeps == []
@pytest.mark.anyio
async def test_init_retries_transient_failure(monkeypatch):
"""A transient console outage is absorbed by retries with backoff."""
client, calls, sleeps = _make_flaky_client(monkeypatch, failures=2)
await client.init(attempts=6)
assert calls == [1, 2, 3]
assert sleeps == [1.0, 2.0]
@pytest.mark.anyio
async def test_init_retries_exhausted_raises(monkeypatch):
"""When every attempt fails, the last error propagates."""
client, calls, sleeps = _make_flaky_client(monkeypatch, failures=99)
with pytest.raises(ConnectionError):
await client.init(attempts=3)
assert calls == [1, 2, 3]
assert sleeps == [1.0, 2.0]
@pytest.mark.anyio
async def test_init_retries_discovery_failure(monkeypatch):
"""Console discovery (not-yet-registered console) is retried too."""
import asyncio
from turnstone.core.tls import TLSClient
client = TLSClient(storage=get_storage(), hostnames=["node-1"])
attempts: list[int] = []
def flaky_discover():
attempts.append(len(attempts) + 1)
if len(attempts) == 1:
raise RuntimeError("No console service found in services table.")
return "http://console:9999"
async def ok():
pass
monkeypatch.setattr(client, "_discover_console_url", flaky_discover)
monkeypatch.setattr(client, "_fetch_ca_cert", ok)
monkeypatch.setattr(client, "_request_cert", ok)
monkeypatch.setattr(asyncio, "sleep", lambda _: ok())
await client.init(attempts=2)
assert attempts == [1, 2]
assert client._console_url == "http://console:9999"
# ── PEM runtime dir ──────────────────────────────────────────────────────────
def test_pem_runtime_dir_env_override(monkeypatch, tmp_path):
"""TURNSTONE_TLS_PEM_DIR overrides the default location."""
from turnstone.core.tls import tls_pem_runtime_dir
monkeypatch.setenv("TURNSTONE_TLS_PEM_DIR", str(tmp_path / "custom"))
assert tls_pem_runtime_dir() == tmp_path / "custom"
def test_pem_runtime_dir_default(monkeypatch):
"""Default lives under the system tempdir."""
import tempfile
from turnstone.core.tls import tls_pem_runtime_dir
monkeypatch.delenv("TURNSTONE_TLS_PEM_DIR", raising=False)
assert tls_pem_runtime_dir() == Path(tempfile.gettempdir()) / "turnstone-tls"
def test_prepare_pem_runtime_dir_clears_stale(monkeypatch, tmp_path):
"""Boot prep creates the dir 0700 and removes stale lacme-pem-* dirs."""
from turnstone.core.tls import prepare_pem_runtime_dir
root = tmp_path / "tls"
monkeypatch.setenv("TURNSTONE_TLS_PEM_DIR", str(root))
stale = root / "lacme-pem-stale"
stale.mkdir(parents=True)
(stale / "key.pem").write_text("old")
(root / "unrelated").mkdir()
result = prepare_pem_runtime_dir()
assert result == root
assert not stale.exists()
assert (root / "unrelated").exists() # only lacme-pem-* is cleared
assert (root.stat().st_mode & 0o777) == 0o700
def test_prepare_pem_runtime_dir_rejects_symlink(monkeypatch, tmp_path):
"""A pre-created symlink at the root must be refused, not followed.
On bare metal the default root sits in shared /tmp; following a
planted symlink would land key material under an attacker-chosen
path."""
from turnstone.core.tls import prepare_pem_runtime_dir
target = tmp_path / "elsewhere"
target.mkdir()
link = tmp_path / "tls-link"
link.symlink_to(target)
monkeypatch.setenv("TURNSTONE_TLS_PEM_DIR", str(link))
with pytest.raises(RuntimeError, match="symlink or not owned"):
prepare_pem_runtime_dir()
def test_refresh_runtime_pems_rotates_dir(monkeypatch, tmp_path):
"""Renewal writes a fresh complete PEM dir, then drops the old one."""
from lacme import CertificateAuthority, MemoryStore
from turnstone.core.tls import prepare_pem_runtime_dir, refresh_runtime_pems
monkeypatch.setenv("TURNSTONE_TLS_PEM_DIR", str(tmp_path / "tls"))
root = prepare_pem_runtime_dir()
ca = CertificateAuthority(store=MemoryStore())
ca.init()
boot_bundle = ca.issue(["node-1", "localhost"])
renewed_bundle = ca.issue(["node-1", "localhost"])
boot = refresh_runtime_pems(boot_bundle, ca_pem=ca.root_cert_pem, previous=None)
boot_dir = boot.cert.parent
assert boot_dir.parent == root
renewed = refresh_runtime_pems(renewed_bundle, ca_pem=ca.root_cert_pem, previous=boot_dir)
new_dir = renewed.cert.parent
assert new_dir.parent == root
assert not boot_dir.exists()
for name in ("fullchain.pem", "key.pem", "ca.pem"):
assert (new_dir / name).is_file()
+1 -1
View File
@@ -1,3 +1,3 @@
"""turnstone - Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."""
__version__ = "1.6.0rc1"
__version__ = "1.6.2"
+48
View File
@@ -439,6 +439,44 @@ def _discover_console_url() -> str:
# ---------------------------------------------------------------------------
def _cmd_orphan_conversations(args: argparse.Namespace) -> None:
"""Scan for (and with --delete purge) conversation rows whose workstream is gone."""
storage = _get_storage(args)
orphans = storage.list_orphan_conversations()
if not orphans:
print("No orphan conversation rows.")
return
width = max(len(o["ws_id"]) for o in orphans)
print(f"{'ws_id':<{width}} {'rows':>5} {'refs':>4} first last")
for o in orphans:
print(
f"{o['ws_id']:<{width}} {o['rows']:>5} {o['attachment_refs']:>4} "
f"{(o['first'] or '')[:10]} {(o['last'] or '')[:10]}"
)
total_rows = sum(o["rows"] for o in orphans)
total_refs = sum(o["attachment_refs"] for o in orphans)
print(
f"\n{len(orphans)} orphan workstream(s), {total_rows} conversation row(s), "
f"{total_refs} attachment ref(s)."
)
if not args.delete:
print("Re-run with --delete to purge them.")
return
if not args.yes:
reply = input(f"Delete {total_rows} row(s) across {len(orphans)} workstream(s)? [y/N] ")
if reply.strip().lower() not in ("y", "yes"):
print("Aborted.", file=sys.stderr)
sys.exit(1)
result = storage.delete_orphan_conversations([o["ws_id"] for o in orphans])
summary = (
f"Purged {result['rows']} row(s) across {result['workstreams']} workstream(s); "
f"released {result['released_refs']} attachment ref(s)"
)
if result["skipped"]:
summary += f"; skipped {result['skipped']} re-registered workstream(s)"
print(summary + ".")
def _cmd_rerank_calibrate(args: argparse.Namespace) -> None:
from turnstone.core.config import get_rerank_instruction
from turnstone.core.config_store import ConfigStore
@@ -619,6 +657,15 @@ def main() -> None:
help="Write the calibration onto the model's capabilities",
)
p_orph = sub.add_parser(
"orphan-conversations",
help="Scan (and with --delete purge) conversation rows whose workstream row is gone",
)
p_orph.add_argument(
"--delete", action="store_true", help="Purge the orphans after the scan report"
)
p_orph.add_argument("--yes", action="store_true", help="Skip the interactive confirmation")
args = parser.parse_args()
if not args.command:
parser.print_help()
@@ -638,6 +685,7 @@ def main() -> None:
"set-node-metadata": _cmd_set_node_metadata,
"delete-node-metadata": _cmd_delete_node_metadata,
"export": _cmd_export,
"orphan-conversations": _cmd_orphan_conversations,
"rerank-calibrate": _cmd_rerank_calibrate,
}
dispatch[args.command](args)
+452 -95
View File
@@ -25,6 +25,7 @@ from __future__ import annotations
import concurrent.futures
import json
import re
import secrets
import threading
import time
@@ -52,16 +53,15 @@ from turnstone.core.workstream import WorkstreamKind
WAIT_REAL_TERMINAL_STATES: frozenset[str] = frozenset({"idle", "error", "closed", "deleted"})
# Reportable terminal states — superset of the real ones, also includes the
# ``denied`` short-circuit shape returned for foreign / missing ws_ids.
# Used inside ``wait_for_workstream`` to decide when ``mode='any'`` on a
# pure-denied list should short-circuit with ``complete=False`` (no real
# work to wait for) and when ``mode='all'`` has fully settled. NOT used
# for the ``mode='any'`` real-terminal completion condition and NOT used
# by the resolved-count summary (which counts only real terminals —
# ``denied`` is a rejection, not a resolution). A single typo'd /
# foreign id shouldn't satisfy ``mode="any"`` and let the model declare
# a wait complete while every real child is still running.
WAIT_TERMINAL_STATES: frozenset[str] = WAIT_REAL_TERMINAL_STATES | frozenset({"denied"})
# ``not_found`` shape returned for foreign / missing / mid-wait-deleted
# ws_ids. ``not_found`` is NOT a completion state: the wait loop fails
# fast the moment any polled id reports it (an unobservable member makes
# the requested wait unsatisfiable — see the fail-fast block in
# ``wait_for_workstream``), and the resolved-count summary counts only
# real terminals. This set's remaining job is the message-sentinel
# branch in the result enrichment: states whose ``message`` is a fixed
# sentinel rather than a storage read.
WAIT_TERMINAL_STATES: frozenset[str] = WAIT_REAL_TERMINAL_STATES | frozenset({"not_found"})
# Hard cap on ws_ids per call. Polling happens once per ws_id per tick, so a
# runaway list would amplify storage load without giving the model anything
@@ -121,9 +121,64 @@ _WAIT_MESSAGE_TAIL_LIMIT: int = 20
# followed by an error) would otherwise produce a sentinel that
# falsely claims no output exists at all.
_WAIT_SENTINEL_CLOSED = "(workstream closed)"
_WAIT_SENTINEL_DENIED = "(workstream denied: not in coordinator subtree or does not exist)"
_WAIT_SENTINEL_NOT_FOUND = "(no workstream with this id among your children)"
_WAIT_SENTINEL_NO_RECENT_ASSISTANT = "(no recent assistant output)"
# ---------------------------------------------------------------------------
# Model-supplied workstream-id validation — the coordinator LLM hand-copies
# ws_ids between tool results and tool calls, and models garble long hex
# runs (the canonical incident: a 32-char id whose ``aaa`` run collapsed to
# a single ``a``, leaving a 30-char id no tool could act on, which then
# read back to the model as a dead child). ws_id arguments are therefore
# validated at the tool boundary:
#
# - a full 32-hex id passes straight through (ownership still enforced by
# the per-verb guards, at unchanged storage cost);
# - a direct child's exact id of any other shape still resolves (legacy /
# synthetic ids predate the 32-hex convention);
# - anything else — truncated, garbled, non-hex, or a display name —
# fails fast with a did-you-mean + a roster of the coordinator's own
# children, so a garbled id is recoverable in one round-trip.
#
# Near-miss ids are NEVER auto-resolved — a mutating verb must not guess.
# Display names are NOT addresses (they're mutable and non-unique); a ref
# matching a child's name errors with a pointer at the right ws_id.
# Validation and every hint consult ONLY the coordinator's own direct
# children, preserving the no-existence-oracle guarantee for foreign ids.
# ---------------------------------------------------------------------------
# A well-formed ws_id: exactly 32 lowercase hex chars (``uuid4().hex``).
_WS_REF_ID_RE = re.compile(r"^[0-9a-f]{32}$")
# Max Levenshtein distance for a did-you-mean candidate. The incident
# class (character-run collapse / duplication / single-char typo) sits at
# distance 1-2; unrelated 32-hex ids sit at ~28+, so 3 is generous
# headroom with no false-positive risk in practice.
_WS_REF_SUGGEST_DISTANCE: int = 3
# Children listed inline in an unresolvable-ws_id error. Enough to
# re-orient the model without flooding the tool result on wide fan-outs;
# the error text points at list_workstreams for the rest.
_WS_REF_ROSTER_CAP: int = 8
# Page size for the validation roster query — far above any practical
# direct-children count, so the exact-match / did-you-mean scans never
# judge against a silently truncated page.
_WS_REF_ROSTER_QUERY_LIMIT: int = 1000
# Did-you-mean candidates surfaced per unresolvable ref.
_WS_REF_SUGGEST_CAP: int = 2
# Echoed-ref clip applied inside error STRINGS — the structured
# ``ws_id`` field carries the full value and the format note reports
# the true length, so the clip only bounds operator-facing text.
# Covers a full 32-hex id with slack.
_WS_REF_ECHO_CLIP: int = 48
# Cap on the assembled top-level ``error`` string when several refs
# fail in one wait call.
_WS_REF_ERROR_TEXT_CAP: int = 2000
_TASK_STATUSES = frozenset({"pending", "in_progress", "done", "blocked"})
# Hard cap on tasks per coordinator — the full list is read and re-serialized
# on every mutation, so unbounded growth is both a storage and a tool-output-size
@@ -142,6 +197,45 @@ _TASK_TITLE_MAX = 200
_LIVE_CACHE_TTL_SECONDS = 2.0
def _levenshtein_capped(a: str, b: str, cap: int) -> int:
"""Levenshtein distance with an early-exit band.
Returns ``cap + 1`` as soon as the distance provably exceeds ``cap``,
so did-you-mean scans across a coordinator's children stay cheap per
candidate instead of O(len^2). Plain DP otherwise inputs are short
(ws_ids are 32 chars) so nothing cleverer is warranted.
"""
if a == b:
return 0
la, lb = len(a), len(b)
if abs(la - lb) > cap:
return cap + 1
if la > lb:
a, b, la, lb = b, a, lb, la
prev = list(range(la + 1))
for j in range(1, lb + 1):
cur = [j] + [0] * la
bj = b[j - 1]
row_best = cur[0]
for i in range(1, la + 1):
cost = 0 if a[i - 1] == bj else 1
cur[i] = min(prev[i] + 1, cur[i - 1] + 1, prev[i - 1] + cost)
row_best = min(row_best, cur[i])
if row_best > cap:
return cap + 1
prev = cur
return prev[la] if prev[la] <= cap else cap + 1
def _trim_ws_ref_hint(payload: dict[str, Any]) -> dict[str, Any]:
"""Per-ref entry for wait's ``invalid_ws_ids`` / ``not_found``
channels one shared shape: ``{ws_id, error, did_you_mean?}``.
The children roster is identical across refs in one call, so it
rides ONCE at the response top level instead of per entry.
"""
return {key: payload[key] for key in ("ws_id", "error", "did_you_mean") if key in payload}
def _utc_now_iso() -> str:
"""ISO-8601 UTC timestamp with seconds precision.
@@ -486,6 +580,177 @@ class CoordinatorClient:
return False
return bool(row.get("user_id")) and row.get("user_id") == self._user_id
# -- model-supplied ws_id validation ------------------------------------
def _children_roster(self) -> list[dict[str, Any]]:
"""Direct children of this coordinator (any kind, own tenant only).
Powers ws_id validation and the did-you-mean / roster blocks in
unresolvable-id errors. Unlike :meth:`list_children` this does
NOT filter ``kind`` a coordinator-kind child passes the
per-verb ownership guards (``_is_own_subtree`` checks parent +
user only), so validation must see the same set or a legacy
exact id could validate for one verb and 404 on another. One
SQL page capped at ``_WS_REF_ROSTER_QUERY_LIMIT``; failures
collapse to an empty roster (hints degrade, validation still
errors honestly).
"""
try:
raw = self._storage.list_workstreams(
limit=_WS_REF_ROSTER_QUERY_LIMIT,
parent_ws_id=self._coord_ws_id,
user_id=self._user_id or None,
)
except Exception:
log.debug("coord_client.ws_ref.roster_failed", exc_info=True)
return []
roster: list[dict[str, Any]] = []
for row in raw:
try:
m = row._mapping # SQLAlchemy Row
except AttributeError:
# Fallback for non-Row tuples (test doubles, etc.) —
# column order mirrors list_children's fallback map.
m = {"ws_id": row[0], "name": row[2], "state": row[3]}
roster.append(
{
"ws_id": str(m["ws_id"] or ""),
"name": str(m["name"] or ""),
"state": str(m["state"] or ""),
}
)
return roster
def _ws_ref_error(
self,
ref: str,
*,
roster: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Uniform unresolvable-ws_id error payload.
One shape for malformed / foreign / nonexistent ids: the message
never distinguishes "exists but isn't yours" from "doesn't
exist" (no existence oracle), and every hint it carries
(did-you-mean, roster) is computed from the coordinator's OWN
children only. Suggestions are advisory text nothing here
auto-resolves, so a near-miss id can never route a mutating verb
to a guessed target.
"""
if roster is None:
roster = self._children_roster()
ref_l = (ref or "").strip().lower()
# Clip the echoed ref in the STRING — a hostile / oversize ws_id
# must not flood operator-facing text (see _WS_REF_ECHO_CLIP).
shown = ref if len(ref) <= _WS_REF_ECHO_CLIP else ref[: _WS_REF_ECHO_CLIP - 3] + "..."
parts = [f"no workstream matching {shown!r} among your children"]
did: list[dict[str, str]] = []
name_hits = [c for c in roster if c["name"] and c["name"].strip().lower() == ref_l]
if name_hits:
# The model pasted a display NAME. Point it straight at the
# id — names are mutable, non-unique labels, deliberately not
# addresses.
did = [
{"ws_id": c["ws_id"], "name": c["name"]} for c in name_hits[:_WS_REF_SUGGEST_CAP]
]
parts.append(
"that is a child NAME, not an id — names are display "
f"labels; did you mean ws_id {did[0]['ws_id']}?"
)
else:
scored = sorted(
(
(_levenshtein_capped(ref_l, c["ws_id"], _WS_REF_SUGGEST_DISTANCE), c)
for c in roster
),
key=lambda pair: pair[0],
)
did = [
{"ws_id": c["ws_id"], "name": c["name"]}
for dist, c in scored
if dist <= _WS_REF_SUGGEST_DISTANCE
][:_WS_REF_SUGGEST_CAP]
if did:
parts.append(
"did you mean "
+ " or ".join(f"{c['ws_id']} ({c['name'] or 'unnamed'})" for c in did)
+ "?"
)
if not _WS_REF_ID_RE.fullmatch(ref_l):
parts.append(
f"ws ids are exactly 32 lowercase hex chars (got {len(ref_l)}) — "
"copy them verbatim from spawn_batch / list_workstreams results"
)
parts.append("use list_workstreams to re-check ids")
payload: dict[str, Any] = {
"error": "; ".join(parts),
"status": 404,
"ws_id": ref,
}
if did:
payload["did_you_mean"] = did
payload["children"] = [
{"ws_id": c["ws_id"], "name": c["name"], "state": c["state"]}
for c in roster[:_WS_REF_ROSTER_CAP]
]
payload["children_truncated"] = len(roster) > _WS_REF_ROSTER_CAP
return payload
def _resolve_ws_ref(
self,
ref: str,
*,
roster: list[dict[str, Any]] | None = None,
) -> tuple[str, dict[str, Any] | None]:
"""Validate a model-supplied ws_id argument.
Returns ``(ws_id, None)`` on success, ``("", error_payload)``
otherwise. Accepted shapes, in match order:
1. the coordinator's own ws_id, verbatim;
2. a full 32-lowercase-hex id (case-folded) passes through
WITHOUT a roster read, so the hot path costs exactly what it
did before validation existed; ownership stays with the
per-verb guards;
3. a direct child's EXACT id of any other shape — covers
legacy / synthetic ids that predate the 32-hex convention.
Anything else truncated, garbled, non-hex, a display name
fails with the did-you-mean payload. The pasted-a-name case is
called out explicitly in the error; near-miss ids are NEVER
auto-resolved.
"""
r = (ref or "").strip()
if not r:
return "", self._ws_ref_error(r, roster=roster)
if r == self._coord_ws_id:
return r, None
rl = r.lower()
if _WS_REF_ID_RE.fullmatch(rl):
return rl, None
if roster is None:
roster = self._children_roster()
for c in roster:
if c["ws_id"] in (r, rl):
return c["ws_id"], None
return "", self._ws_ref_error(r, roster=roster)
def _resolve_owned(self, ws_id: str) -> tuple[str, dict[str, Any] | None]:
"""Resolve + ownership-guard a model-supplied ws_id in one step.
Shared preamble for the mutating verbs (send / close / cancel /
delete): format-resolve via :meth:`_resolve_ws_ref`, then the
tenant gate via :meth:`_is_own_subtree`. Returns
``(ws_id, None)`` or ``("", error_payload)`` one home so a
future guard change (audit hook, logging) lands once.
"""
resolved, ref_err = self._resolve_ws_ref(ws_id)
if ref_err is not None:
return "", ref_err
if not self._is_own_subtree(resolved):
return "", self._ws_ref_error(resolved)
return resolved, None
# -- model-invoked mutating ops (HTTP) ---------------------------------
def spawn(
@@ -517,9 +782,10 @@ class CoordinatorClient:
return self._post("spawn", body)
def send(self, ws_id: str, message: str) -> dict[str, Any]:
if not self._is_own_subtree(ws_id):
return {"error": f"workstream not in coordinator subtree: {ws_id}", "status": 404}
return self._post("send", {"message": message}, ws_id=ws_id)
resolved, ref_err = self._resolve_owned(ws_id)
if ref_err is not None:
return ref_err
return self._post("send", {"message": message}, ws_id=resolved)
def emit_audit(self, action: str, detail: dict[str, Any]) -> None:
"""Record an audit row attributed to this coordinator session.
@@ -541,12 +807,13 @@ class CoordinatorClient:
)
def close_workstream(self, ws_id: str, reason: str = "") -> dict[str, Any]:
if not self._is_own_subtree(ws_id):
return {"error": f"workstream not in coordinator subtree: {ws_id}", "status": 404}
resolved, ref_err = self._resolve_owned(ws_id)
if ref_err is not None:
return ref_err
body: dict[str, Any] = {}
if reason:
body["reason"] = reason
return self._post("close", body, ws_id=ws_id)
return self._post("close", body, ws_id=resolved)
def close_all_children(self, reason: str = "") -> dict[str, Any]:
"""Soft-close every direct child of this coordinator (console-side fan-out).
@@ -563,9 +830,10 @@ class CoordinatorClient:
return self._post("close_all_children", body, ws_id=self._coord_ws_id)
def delete(self, ws_id: str) -> dict[str, Any]:
if not self._is_own_subtree(ws_id):
return {"error": f"workstream not in coordinator subtree: {ws_id}", "status": 404}
return self._post("delete", {"ws_id": ws_id})
resolved, ref_err = self._resolve_owned(ws_id)
if ref_err is not None:
return ref_err
return self._post("delete", {"ws_id": resolved})
# -- console-endpoint helpers (NOT model-invoked tools) -----------------
@@ -587,9 +855,10 @@ class CoordinatorClient:
return self._post("approve", body, ws_id=ws_id)
def cancel(self, ws_id: str) -> dict[str, Any]:
if not self._is_own_subtree(ws_id):
return {"error": f"workstream not in coordinator subtree: {ws_id}", "status": 404}
return self._post("cancel", {}, ws_id=ws_id)
resolved, ref_err = self._resolve_owned(ws_id)
if ref_err is not None:
return ref_err
return self._post("cancel", {}, ws_id=resolved)
def rewind(self, ws_id: str, turns: int) -> dict[str, Any]:
if not self._is_own_subtree(ws_id):
@@ -630,16 +899,18 @@ class CoordinatorClient:
because hard-delete cascades the row out of storage, but it
stays in the set so a legacy / synthetic-test row carrying
that state still counts). ``mode='all'`` returns once every
ws_id has settled (real terminal OR ``denied``). Returns
``{"results": {ws_id: {state, tokens, updated, message, truncated}},
"elapsed": float, "complete": bool, "mode": mode}``. ``complete``
is True when the wait condition was met before the deadline,
False when the timeout fired (results carry whatever last state
was observed).
ws_id is real-terminal an id that can't be observed never
rides along to a "complete" result (see the not_found fail-fast
below). Returns
``{"results": {ws_id: {state, tokens, updated, name, message,
truncated}}, "elapsed": float, "complete": bool, "mode": mode}``.
``complete`` is True when the wait condition was met before the
deadline, False when the timeout fired (results carry whatever
last state was observed).
``message`` carries the child's last assistant message text for
``idle`` / ``error`` states, or a short status sentinel for
``closed`` / ``denied``. Non-terminal entries (e.g. ``running``
``closed`` / ``not_found``. Non-terminal entries (e.g. ``running``
after a timeout) and ``deleted`` rows carry ``None`` hard
deletes cascade rows out of storage so a real ``deleted`` state
is never observed; the legacy/synthetic-row path falls into the
@@ -665,13 +936,27 @@ class CoordinatorClient:
dict from silently exiting on tick one (which the naive
missing-entry-counts-as-changed rule would cause).
Cross-tenant guard: a ws_id that's neither the coordinator
itself nor one of its own children appears with
``state="denied"`` and never blocks the wait a model that
emits a foreign id learns immediately rather than spinning
until timeout. A ws_id that doesn't exist at all collapses
into the same ``denied`` shape so wait can't be used as an
existence oracle.
Unresolvable ids fail fast. Refs are validated up front a
malformed ws_id (truncated / garbled / non-hex / a display
name) errors immediately, before any waiting happens, with
top-level ``error`` / ``invalid_ws_ids`` / ``children`` fields.
Per-ref entries in ``invalid_ws_ids`` and ``not_found`` share
one shape ``{ws_id, error, did_you_mean}`` and the children
roster rides once at top level on both channels. A
well-formed id that is foreign, nonexistent, or hard-deleted
mid-wait surfaces as ``state="not_found"`` and aborts the wait
on the tick that observes it: ``complete=False`` plus top-level
``error`` / ``not_found`` / ``children`` fields. Without this,
an unobservable member either burns the whole timeout
(``mode='all'`` could never satisfy) or silently rides along to
a "complete" result missing a lane the original incident
shape. Foreign and nonexistent ids collapse into one
indistinguishable payload (no existence oracle); every hint
references only this coordinator's own children. Results are
keyed by the validated ws_id and share one key set
``not_found`` entries carry empty ``updated`` / ``name`` with
the display ``name`` filled in for own children as orientation
(names are labels, NOT addresses).
``progress_callback`` is invoked once per poll cycle with the
current snapshot dict + elapsed seconds. Swallows callback
@@ -731,6 +1016,45 @@ class CoordinatorClient:
"elapsed": 0.0,
"mode": mode,
}
# Validate model-supplied refs before anything else touches them.
# The roster query is skipped when every ref is already the coord
# itself or a full 32-hex id (the overwhelmingly common case), so
# validation adds no storage cost to the hot path.
needs_roster = any(
w != self._coord_ws_id and not _WS_REF_ID_RE.fullmatch(w.lower()) for w in cleaned
)
roster = self._children_roster() if needs_roster else None
resolved_ids: list[str] = []
resolved_seen: set[str] = set()
invalid: list[dict[str, Any]] = []
for ref in cleaned:
rid, ref_err = self._resolve_ws_ref(ref, roster=roster)
if ref_err is not None:
invalid.append(ref_err)
continue
if rid not in resolved_seen:
resolved_seen.add(rid)
resolved_ids.append(rid)
if invalid:
# Tool-boundary fail-fast: error the whole call rather than
# waiting on the valid subset — the model asked to observe a
# set it can't observe, and a partial wait hides exactly the
# lost-lane failure this guards against. Entries share the
# ``not_found`` channel's per-ref shape; the roster rides
# once at top level.
return {
"error": " | ".join(str(e.get("error") or "") for e in invalid)[
:_WS_REF_ERROR_TEXT_CAP
],
"invalid_ws_ids": [_trim_ws_ref_hint(e) for e in invalid],
"children": invalid[0].get("children", []),
"children_truncated": invalid[0].get("children_truncated", False),
"results": {},
"complete": False,
"elapsed": 0.0,
"mode": mode,
}
cleaned = resolved_ids
try:
timeout_f = float(timeout)
except (TypeError, ValueError):
@@ -755,7 +1079,8 @@ class CoordinatorClient:
aggregate batch) instead of two-per-id, cutting per-tick
round-trips from O(N) to O(1) at the documented cap.
Cross-tenant + missing-row cases collapse into a single
``denied`` shape so wait can't be used as an existence oracle.
``not_found`` shape so wait can't be used as an existence
oracle.
"""
try:
rows = self._storage.get_workstreams_batch(cleaned)
@@ -771,29 +1096,30 @@ class CoordinatorClient:
for wid in cleaned:
row = rows.get(wid)
if row is None or not self._row_in_own_subtree(wid, row):
snaps[wid] = {"state": "denied", "tokens": 0}
# Same key set as real entries (updated/name empty)
# so callers consume results[ws_id] uniformly.
snaps[wid] = {
"state": "not_found",
"tokens": 0,
"updated": "",
"name": "",
}
continue
snaps[wid] = {
"state": str(row.get("state") or ""),
"tokens": int(tokens_by_wid.get(wid, 0) or 0),
"updated": row.get("updated") or "",
"name": str(row.get("name") or ""),
}
return snaps
def _is_real_terminal(snap: dict[str, Any]) -> bool:
# Real-terminal — these states drive ``complete=True``.
# ``denied`` is intentionally excluded so a single typo'd /
# foreign / nonexistent ws_id can't satisfy ``mode="any"``
# while every real child is still running.
# ``not_found`` is intentionally excluded: an unobservable
# member can't satisfy ``mode="any"`` — it aborts the wait
# via the fail-fast below instead.
return snap.get("state", "") in self._WAIT_REAL_TERMINAL_STATES
def _is_settled(snap: dict[str, Any]) -> bool:
# Settled — terminal OR denied. Used to decide when the
# wait should give up because there's nothing left to
# observe (no real ws_ids in the polled set, or every real
# one has already finished).
return snap.get("state", "") in self._WAIT_TERMINAL_STATES
def _diff_since(snap: dict[str, Any], prev: dict[str, Any]) -> bool:
"""True when ``snap`` differs from the ``since`` hint on any
of the diffed fields. Called only for ws_ids that appear in
@@ -804,6 +1130,7 @@ class CoordinatorClient:
last_results: dict[str, dict[str, Any]] = {}
complete = False
not_found_ids: list[str] = []
# Subscribe to in-process state-change events for the watched
# ws_ids when the bus is wired. ``register_waiter`` returns a
# single ``threading.Event`` registered against every id so a
@@ -816,13 +1143,13 @@ class CoordinatorClient:
# registry on this console process, so a foreign ws_id passed by
# an untrusted coord LLM (prompt injection) would otherwise leak
# wake-up timing as a side channel — _snapshot_all returns
# ``denied`` for the content, but the *time* at which the wait
# ``not_found`` for the content, but the *time* at which the wait
# un-blocked would correlate with the foreign ws_id's next
# state-class event. Filter ``cleaned`` to own-subtree ids
# before registering; foreign / missing ws_ids stay in the
# snapshot list so they still surface as ``denied`` in
# ``_snapshot_all`` and exit via the pure-denied short-circuit
# below. Predicate shared with ``_snapshot_all`` via
# snapshot list so they still surface as ``not_found`` in
# ``_snapshot_all`` and exit via the not_found fail-fast in the
# loop. Predicate shared with ``_snapshot_all`` via
# :meth:`_row_in_own_subtree`.
try:
pre_rows = self._storage.get_workstreams_batch(cleaned)
@@ -849,7 +1176,20 @@ class CoordinatorClient:
except Exception:
log.debug("coord_client.wait.progress_cb_failed", exc_info=True)
real_terminal = [_is_real_terminal(snap) for snap in results.values()]
settled = [_is_settled(snap) for snap in results.values()]
# Fail fast on unobservable members — foreign, nonexistent,
# or hard-deleted mid-wait. Checked BEFORE the since-diff
# and mode conditions: an unobservable member invalidates
# the requested wait regardless of what the rest are doing
# (``mode='all'`` could never satisfy; ``mode='any'`` /
# ``since`` could "succeed" while silently dropping a
# lane). The snapshot already collapsed foreign and
# missing into one ``not_found`` shape, so exiting here
# leaks nothing a single-tick wait wouldn't.
not_found_ids = [
wid for wid, snap in results.items() if snap.get("state") == "not_found"
]
if not_found_ids:
break
# ``since`` — orthogonal to mode. If the caller supplied a
# prior snapshot, any diff on a ws_id that IS in ``since_map``
# exits the wait so a follow-up call doesn't re-count
@@ -869,19 +1209,13 @@ class CoordinatorClient:
if any(real_terminal):
complete = True
break
# Pure-denied list: every snap is settled but none is a
# real terminal — no work to wait for. Short-circuit so
# the model sees the denied results immediately rather
# than spinning the timeout (``complete=False`` because
# the wait condition never had a real chance to fire).
if all(settled):
break
else: # mode == "all"
if all(settled):
# Every ws_id is settled (real-terminal or denied).
# The wait condition is met — the model gets the
# full results dict and decides what each terminal
# state means.
if all(real_terminal):
# Every ws_id actually finished observable work.
# ``not_found`` members can't ride along to a
# "complete" result — the fail-fast above exits
# first — so ``complete=True`` means every lane
# really resolved.
complete = True
break
remaining = deadline - time.monotonic()
@@ -898,14 +1232,13 @@ class CoordinatorClient:
# a deliberate 4x trade vs the pre-bus 0.5 s poll.
wake_event.wait(min(remaining, self._WAIT_HEARTBEAT_INTERVAL))
else:
# Pure-foreign / pure-denied list: every cleaned
# ws_id was filtered out of ``own_subtree`` so the
# bus has nothing to wake on. The pure-denied
# short-circuit above exits ``mode='any'`` on the
# first tick; ``mode='all'`` falls through to here
# and must burn the timeout. Use the heartbeat
# cadence for the deadline carve-up so
# ``progress_callback`` keeps firing.
# No wake source for this wait (no registered own-
# subtree ids — e.g. a bus-less test fixture). Fall
# back to the heartbeat cadence so
# ``progress_callback`` keeps firing. Foreign /
# missing ids can't park here past one tick: the
# not_found fail-fast above exits on the tick that
# observes them.
time.sleep(min(self._WAIT_HEARTBEAT_INTERVAL, remaining))
finally:
# Always unregister so a crash mid-wait can't leak the
@@ -920,7 +1253,7 @@ class CoordinatorClient:
# Bundle each terminal child's last assistant message inline so the
# coordinator LLM doesn't have to follow up with one
# ``inspect_workstream`` per ws. Only ``idle`` / ``error`` ws_ids
# actually hit storage (``closed`` / ``denied`` return a sentinel
# actually hit storage (``closed`` / ``not_found`` return a sentinel
# without I/O), so split them and parallelize the storage-bound
# subset across a small thread pool — at the WAIT_MAX_WS_IDS=32
# cap, 8 workers cuts a worst-case all-idle fan-out from 32
@@ -965,12 +1298,30 @@ class CoordinatorClient:
else:
msg, trunc = None, False
enriched_results[wid] = {**snap, "message": msg, "truncated": trunc}
return {
response: dict[str, Any] = {
"results": enriched_results,
"complete": complete,
"elapsed": round(time.monotonic() - start, 3),
"mode": mode,
}
if not_found_ids:
# The wait aborted on unobservable members — surface a loud
# top-level error with recovery hints (did-you-mean + child
# roster) so a garbled id reads as "fix the id and re-issue",
# not as a dead child. Hints reference only this
# coordinator's own children; foreign and nonexistent ids
# produce identical payloads (no existence oracle).
hint_roster = self._children_roster()
hints = [self._ws_ref_error(wid, roster=hint_roster) for wid in not_found_ids]
response["not_found"] = [_trim_ws_ref_hint(h) for h in hints]
response["error"] = " | ".join(str(h.get("error") or "") for h in hints)[
:_WS_REF_ERROR_TEXT_CAP
]
response["children"] = hints[0].get("children", []) if hints else []
response["children_truncated"] = (
hints[0].get("children_truncated", False) if hints else False
)
return response
# -- model-invoked read ops (direct storage) ---------------------------
@@ -1495,10 +1846,11 @@ class CoordinatorClient:
Cross-tenant guard: the coordinator's LLM input is untrusted, so
the inspectable scope is restricted to (a) the coordinator
itself or (b) a row whose ``parent_ws_id`` is this coordinator
(i.e. one of its own children). Any other ws_id returns the
same not-found shape used for genuine misses, avoiding an
existence oracle.
itself or (b) one of its own children ``parent_ws_id`` AND
``user_id`` parity via :meth:`_row_in_own_subtree`, matching
the wait / mutating paths. Any other ws_id returns the same
not-found shape used for genuine misses, avoiding an existence
oracle.
``include_provider_content`` defaults to False. Provider-native
content blocks (``_provider_content`` / ``provider_blocks``)
@@ -1507,20 +1859,25 @@ class CoordinatorClient:
them for provider-fidelity replay tooling; regular inspect
calls get the trimmed shape.
"""
resolved, ref_err = self._resolve_ws_ref(ws_id)
if ref_err is not None:
return ref_err
ws_id = resolved
full = self._storage.get_workstream(ws_id)
# Echoing the ws_id back inside the error STRING was a stylistic
# carry-over — the structured ``ws_id`` field already carries
# the value the caller asked about. The bare error message
# ("workstream not found") is enough; the same shape is used
# for cross-tenant rows so the existence-leak guarantee is
# preserved either way.
miss = {"error": "workstream not found", "ws_id": ws_id}
# Misses return the same did-you-mean payload for nonexistent and
# cross-tenant rows alike, so the existence-leak guarantee is
# preserved while a garbled id stays recoverable in one
# round-trip (the structured ``ws_id`` field echoes the value
# the caller asked about).
if full is None:
return miss
is_self = ws_id == self._coord_ws_id
is_own_child = full.get("parent_ws_id") == self._coord_ws_id
if not (is_self or is_own_child):
return miss
return self._ws_ref_error(ws_id)
# Ownership parity with every other verb: parent AND user_id
# (``_row_in_own_subtree``). The parent-only check this
# replaces let a forged / migration-era row (parent_ws_id=coord,
# user_id=other-tenant) be read through inspect while the wait
# and mutating paths rejected the same shape (#506).
if not self._row_in_own_subtree(ws_id, full):
return self._ws_ref_error(ws_id)
# load_messages returns the full history in chronological order.
# We slice the tail in Python because the SQL tail-N is
# approximate across conversation boundaries. Defensive
@@ -2103,7 +2460,7 @@ def _wait_message_for(
exhaustion is more actionable than the prior assistant turn,
and the prior shape's "(no recent assistant output)" sentinel
hid that signal entirely.
- ``closed`` / ``denied`` short status sentinel. No
- ``closed`` / ``not_found`` short status sentinel. No
message-history read because there's nothing meaningful to
return a partial last message could be misleading mid-thought.
- any other state (e.g. ``running``, or a ``deleted`` synthetic /
@@ -2117,8 +2474,8 @@ def _wait_message_for(
already completed; the model just gets ``message: null`` for the
affected ws and can fall back to inspect).
"""
if state == "denied":
return _WAIT_SENTINEL_DENIED, False
if state == "not_found":
return _WAIT_SENTINEL_NOT_FOUND, False
if state == "closed":
return _WAIT_SENTINEL_CLOSED, False
if state == "error":
+76 -8
View File
@@ -5555,18 +5555,31 @@ def _normalize_task_dict(task: dict[str, Any]) -> dict[str, Any]:
return task
def _next_cron_runs(cron_expr: str, count: int) -> list[str] | None:
"""Next *count* firings of *cron_expr* as naive-UTC ISO strings.
Returns None for expressions that pass croniter.is_valid but can never
match a real calendar date (``0 0 30 2 *`` get_next raises
CroniterBadDateError after exhausting its search window).
"""
from datetime import UTC, datetime
from croniter import CroniterBadDateError, croniter
cron = croniter(cron_expr, datetime.now(UTC))
try:
return [cron.get_next(datetime).strftime("%Y-%m-%dT%H:%M:%S") for _ in range(count)]
except CroniterBadDateError:
return None
def _compute_next_run(schedule_type: str, cron_expr: str, at_time: str) -> str:
"""Compute the next run time for a schedule. Empty string if invalid."""
if schedule_type == "at":
return at_time
if schedule_type == "cron" and cron_expr:
from datetime import UTC, datetime
from croniter import croniter
cron = croniter(cron_expr, datetime.now(UTC))
next_dt = cron.get_next(datetime)
return str(next_dt.strftime("%Y-%m-%dT%H:%M:%S"))
runs = _next_cron_runs(cron_expr, 1)
return runs[0] if runs else ""
return ""
@@ -5599,6 +5612,52 @@ def _validate_schedule_fields(schedule_type: str, cron_expr: str, at_time: str)
return None
async def admin_preview_schedule(request: Request) -> JSONResponse:
"""POST /v1/api/admin/schedules/preview — validate timing, return next runs.
Pure compute (no storage): powers the schedule editor's NEXT RUNS read-out,
re-queried as the user types. Invalid input is a normal preview outcome
(the read-out renders the message live), so it answers 200 with
``valid: false`` rather than a 4xx.
"""
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import read_json_or_400
err = require_permission(request, "admin.schedules")
if err:
return err
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
schedule_type = str(body.get("schedule_type", "")).strip()
cron_expr = str(body.get("cron_expr", "")).strip()[:256]
at_time = str(body.get("at_time", "")).strip()[:64]
verr = _validate_schedule_fields(schedule_type, cron_expr, at_time)
if verr:
return JSONResponse({"valid": False, "error": verr, "next": []})
if schedule_type == "at":
return JSONResponse({"valid": True, "error": "", "next": [at_time]})
runs = _next_cron_runs(cron_expr, 3)
if runs is None:
# croniter.is_valid passes these, but the date never exists
# (e.g. ``0 0 30 2 *``) — a preview outcome, not a server error.
return JSONResponse(
{
"valid": False,
"error": "Cron expression never matches a real calendar date",
"next": [],
}
)
# The 'at' branch echoes an offset-bearing ISO; keep next[] uniform so
# consumers parse every element identically.
return JSONResponse({"valid": True, "error": "", "next": [r + "+00:00" for r in runs]})
async def admin_list_schedules(request: Request) -> JSONResponse:
"""GET /v1/api/admin/schedules — list all scheduled tasks."""
from turnstone.core.auth import require_permission
@@ -10151,7 +10210,9 @@ async def admin_import_mcp_config(request: Request) -> JSONResponse:
# ---------------------------------------------------------------------------
_MODEL_ALIAS_RE = re.compile(r"^[a-zA-Z0-9._-]+$")
_MODEL_PROVIDERS = frozenset({"openai", "anthropic", "openai-compatible", "google", "xai"})
_MODEL_PROVIDERS = frozenset(
{"openai", "anthropic", "openai-compatible", "anthropic-compatible", "google", "xai"}
)
_REASONING_EFFORT_CHOICES = frozenset(
{"", "none", "minimal", "low", "medium", "high", "xhigh", "max"}
)
@@ -13047,6 +13108,13 @@ def create_app(
),
Route("/api/admin/schedules", admin_list_schedules),
Route("/api/admin/schedules", admin_create_schedule, methods=["POST"]),
# Registered before the {task_id} routes so the literal
# segment wins the match.
Route(
"/api/admin/schedules/preview",
admin_preview_schedule,
methods=["POST"],
),
Route("/api/admin/schedules/{task_id}", admin_get_schedule),
Route("/api/admin/schedules/{task_id}", admin_update_schedule, methods=["PUT"]),
Route(
File diff suppressed because it is too large Load Diff
+121 -127
View File
@@ -1740,10 +1740,7 @@ function loadSavedCoordinators() {
// Belt-and-braces: if the user entered delete mode while this
// fetch was already in flight, defer the render — re-rendering
// mid-selection would shuffle visible cards and reshape selections.
if (
_coordTable &&
_coordTable.controller.inMode()
) {
if (_coordTable && _coordTable.controller.inMode()) {
_savedCoordsRetry = true;
return;
}
@@ -1777,131 +1774,134 @@ let _coordTable = null;
// deferred ES module now, so its bridged globals (SavedColumns,
// createSavedTable) don't exist yet while this classic file parses.
function _initSavedCoordTable() {
const COORD_COLUMNS = [
SavedColumns.name(),
{
key: "kind",
label: "KIND",
width: "62px",
cell: function (s) {
const tag = document.createElement("span");
const coord = s.kind === "coordinator";
tag.className = "persona-tag" + (coord ? " coord" : " int");
tag.textContent = coord ? "COORD" : "INT";
return tag;
const COORD_COLUMNS = [
SavedColumns.name(),
{
key: "kind",
label: "KIND",
width: "62px",
cell: function (s) {
const tag = document.createElement("span");
const coord = s.kind === "coordinator";
tag.className = "persona-tag" + (coord ? " coord" : " int");
tag.textContent = coord ? "COORD" : "INT";
return tag;
},
sort: function (s) {
return s.kind || "";
},
},
sort: function (s) {
return s.kind || "";
SavedColumns.model(),
SavedColumns.count("child_count", "CHILDREN", "92px"),
SavedColumns.ctx(),
SavedColumns.last(),
SavedColumns.id(),
];
_coordTable = createSavedTable({
headerEl: document.getElementById("coord-saved-colheaders"),
bodyEl: document.getElementById("saved-coord-cards"),
filterEl: document.getElementById("coord-filter"),
footerEl: document.getElementById("coord-saved-footer"),
paginationEl: document.getElementById("coord-pagination"),
columns: COORD_COLUMNS,
noun: "session",
emptyText: "No saved sessions",
activateLabel: function (s) {
return (
"Resume " +
(s.kind === "coordinator" ? "coordinator" : "session") +
": " +
(s.alias || s.title || s.name || s.ws_id)
);
},
},
SavedColumns.model(),
SavedColumns.count("child_count", "CHILDREN", "92px"),
SavedColumns.ctx(),
SavedColumns.last(),
SavedColumns.id(),
];
_coordTable = createSavedTable({
headerEl: document.getElementById("coord-saved-colheaders"),
bodyEl: document.getElementById("saved-coord-cards"),
filterEl: document.getElementById("coord-filter"),
footerEl: document.getElementById("coord-saved-footer"),
paginationEl: document.getElementById("coord-pagination"),
columns: COORD_COLUMNS,
noun: "session",
emptyText: "No saved sessions",
activateLabel: function (s) {
return (
"Resume " +
(s.kind === "coordinator" ? "coordinator" : "session") +
": " +
(s.alias || s.title || s.name || s.ws_id)
);
},
onActivate: function (s, rowEl) {
// Open the session as an L-shell PANE (the renovation: rail + saved-list
// clicks open tabs, not full-page nav). Fall back to full-page nav only if
// the shell isn't present.
const pm = window.TS_SHELL && window.TS_SHELL.panes;
// Interactive sessions live on a compute node and, unlike coordinators,
// have no warm pool: a dormant one must be routed to a live node and
// rehydrated there before its pane can stream. The interactive pane does
// exactly that on first activate (resolveInteractiveNode: origin-first
// POST /open with a rendezvous fallback), so the saved-row click just opens
// the pane with the origin node as the hint. Shell-absent falls back to a
// best-effort full-page nav to the origin node, whose detail page
// rehydrates lazily.
if (s.kind !== "coordinator") {
if (pm) {
pm.openPane("interactive", s.ws_id, { nodeId: s.node_id || null });
} else if (s.node_id) {
window.location.href =
"/node/" +
encodeURIComponent(s.node_id) +
"/?ws_id=" +
encodeURIComponent(s.ws_id);
} else {
showToast("Session node unknown");
}
return;
}
// POST /open BEFORE navigating so capacity issues surface as a toast
// instead of a broken-looking detail page.
if (rowEl) rowEl.classList.add("is-busy");
authFetch("/v1/api/workstreams/" + encodeURIComponent(s.ws_id) + "/open", {
method: "POST",
})
.then(function (r) {
if (r.ok) {
if (rowEl) rowEl.classList.remove("is-busy");
if (pm) pm.openPane("coordinator", s.ws_id);
else
window.location.href =
"/coordinator/" + encodeURIComponent(s.ws_id);
return;
}
if (rowEl) rowEl.classList.remove("is-busy");
if (r.status === 429) {
showToast(
"All coordinator slots are active — close one first to restore this session",
);
} else if (r.status === 404) {
showToast("Coordinator no longer available");
loadSavedCoordinators();
} else if (r.status === 503) {
showToast("Coordinator subsystem not configured");
onActivate: function (s, rowEl) {
// Open the session as an L-shell PANE (the renovation: rail + saved-list
// clicks open tabs, not full-page nav). Fall back to full-page nav only if
// the shell isn't present.
const pm = window.TS_SHELL && window.TS_SHELL.panes;
// Interactive sessions live on a compute node and, unlike coordinators,
// have no warm pool: a dormant one must be routed to a live node and
// rehydrated there before its pane can stream. The interactive pane does
// exactly that on first activate (resolveInteractiveNode: origin-first
// POST /open with a rendezvous fallback), so the saved-row click just opens
// the pane with the origin node as the hint. Shell-absent falls back to a
// best-effort full-page nav to the origin node, whose detail page
// rehydrates lazily.
if (s.kind !== "coordinator") {
if (pm) {
pm.openPane("interactive", s.ws_id, { nodeId: s.node_id || null });
} else if (s.node_id) {
window.location.href =
"/node/" +
encodeURIComponent(s.node_id) +
"/?ws_id=" +
encodeURIComponent(s.ws_id);
} else {
showToast("Failed to restore coordinator (" + r.status + ")");
showToast("Session node unknown");
}
})
.catch(function () {
if (rowEl) rowEl.classList.remove("is-busy");
showToast("Failed to restore coordinator");
});
},
delete: {
idPrefix: "coord-delete",
buttonId: "coord-delete-btn",
// Coordinators live on whichever node owns the ws_id; the router proxy
// reads ws_id from the body, resolves the owning node via rendezvous
// hashing, and forwards to that node's POST workstreams/{ws_id}/delete.
buildDeleteRequest: function (wsId) {
return {
url: "/v1/api/route/workstreams/delete",
options: {
return;
}
// POST /open BEFORE navigating so capacity issues surface as a toast
// instead of a broken-looking detail page.
if (rowEl) rowEl.classList.add("is-busy");
authFetch(
"/v1/api/workstreams/" + encodeURIComponent(s.ws_id) + "/open",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ws_id: wsId }),
},
};
)
.then(function (r) {
if (r.ok) {
if (rowEl) rowEl.classList.remove("is-busy");
if (pm) pm.openPane("coordinator", s.ws_id);
else
window.location.href =
"/coordinator/" + encodeURIComponent(s.ws_id);
return;
}
if (rowEl) rowEl.classList.remove("is-busy");
if (r.status === 429) {
showToast(
"All coordinator slots are active — close one first to restore this session",
);
} else if (r.status === 404) {
showToast("Coordinator no longer available");
loadSavedCoordinators();
} else if (r.status === 503) {
showToast("Coordinator subsystem not configured");
} else {
showToast("Failed to restore coordinator (" + r.status + ")");
}
})
.catch(function () {
if (rowEl) rowEl.classList.remove("is-busy");
showToast("Failed to restore coordinator");
});
},
onClose: function () {
// Drain queued retries before the explicit reload (see the freeze
// gate in loadSavedCoordinators) so .finally() doesn't double-fetch.
_savedCoordsRetry = false;
loadSavedCoordinators();
delete: {
idPrefix: "coord-delete",
buttonId: "coord-delete-btn",
// Coordinators live on whichever node owns the ws_id; the router proxy
// reads ws_id from the body, resolves the owning node via rendezvous
// hashing, and forwards to that node's POST workstreams/{ws_id}/delete.
buildDeleteRequest: function (wsId) {
return {
url: "/v1/api/route/workstreams/delete",
options: {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ws_id: wsId }),
},
};
},
onClose: function () {
// Drain queued retries before the explicit reload (see the freeze
// gate in loadSavedCoordinators) so .finally() doesn't double-fetch.
_savedCoordsRetry = false;
loadSavedCoordinators();
},
},
},
});
});
}
// HTML inline-onclick wrappers — keep the global names the markup binds
@@ -1924,12 +1924,6 @@ function toggleCoordSelectAll() {
function confirmCoordDeleteSelection() {
_coordTable.controller.confirmSelection();
}
function cancelCoordDelete() {
_coordTable.controller.closeModal();
}
function confirmCoordDelete() {
_coordTable.controller.confirm();
}
// --- Init ---
// SSE connects after auth is confirmed — either via onLoginSuccess after
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+104 -445
View File
@@ -290,10 +290,10 @@
so the two dashboards share one source of truth. */
/* ==========================================================================
Toast override position above cluster status bar AND above admin modal
overlays (which sit at z-index 600). Without this, toasts fired while a
modal is open e.g. paste-to-fill on the Create Skill modal render
behind the dimmed backdrop and never reach the user.
Toast override sit above the cluster status bar. Stacking against the
hatch dialogs needs no z-index at all: a document-modal <dialog> owns the
top layer, and toast.js promotes the toast to a manual popover (also top
layer, later = higher) while one is open.
========================================================================== */
#toast {
bottom: 56px;
@@ -411,11 +411,20 @@
flex: 1;
}
/* Content area */
/* Content area the manage pane's interior scroller. The chain above it
(#view-admin .admin-layout) is height-pinned by the L-shell pane and the
.hatch-host clips, so this is the last box that can own tab overflow. It
scrolls so the docked shelf positioned against .admin-layout, OUTSIDE
this scroller stays put while tab content moves beneath it; the same
division of labor as #main inside the dashboard pane. */
.admin-content {
flex: 1;
min-width: 0;
overflow-y: auto;
padding-right: 20px;
/* scroll tail the last row must not hug the pane edge (#main keeps 60px;
the admin tables are denser, so less) */
padding-bottom: 24px;
}
.admin-toolbar {
@@ -746,9 +755,10 @@
text-overflow "…". The actions cell inherits `.admin-col`'s
`overflow: hidden`, which would re-clip a dropdown, so we override it
to `visible` and anchor an absolutely-positioned menu to the kebab
container. `.admin-content` does not scroll (the document does), so
the menu glued to its cell overlays the page without being
clipped by any ancestor. */
container. The menu lives inside the `.admin-content` scroller and
rides with its row; the viewport-aware flip-up in _initKebabMenus
keeps bottom rows' menus inside the visible box, and scrolling
dismisses an open menu before the scroller's edge could clip it. */
.admin-col-actions,
.admin-col-mactions {
overflow: visible;
@@ -913,25 +923,16 @@
color: var(--accent);
}
/* Two-column form layout for wide modals */
/* Two-column read-out grid survives for the MCP-detail inspect shelf
(admin.js _openMcpDetail builds .modal-columns/.modal-col). */
.modal-columns {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0;
}
.modal-col > label:first-child,
.modal-col > .modal-col-heading + label {
.modal-col > label:first-child {
margin-top: 0;
}
.modal-col-heading {
font-family: var(--font-ui);
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--accent);
margin-bottom: 10px;
}
.modal-columns > .modal-col:first-child {
border-right: 1px solid var(--border);
padding-right: 12px;
@@ -953,18 +954,18 @@
* <span class="toggle-label">Enabled</span>
* </label>
*/
/* Selector is doubled with .admin-modal so we win the specificity
* battle against ``.admin-modal label`` (0,1,1) without that, the
* parent rule's display:block + text-transform:uppercase + margins
* cascade and we lose the inline-flex layout.
*
* Default margin-top: 14px matches the .admin-modal label cadence
* for toggles that sit directly between regular labelled rows
* (schedule/policy/skill modals). When a toggle is inside an
* explicit ``.toggle-stack`` flex container, the stack resets the
* margin so the parent's gap controls spacing on its own. */
.admin-modal label.toggle-switch,
/* Default margin-top: 14px matches the shelf form cadence for toggles
* that sit directly between regular labelled rows. When a toggle is
* inside an explicit ``.toggle-stack`` flex container, the stack resets
* the margin so the parent's gap controls spacing on its own. (Inside
* a hatch body, hatch.css restates this layout at higher specificity
* the .sh-body label.toggle-switch exception.) */
label.toggle-switch {
/* The containing block for the visually-hidden abspos input below: without
it the input sits at its static position outside whatever scroller the
toggle lives in (.sh-body, .admin-content) and focus-scrolls the wrong
ancestor inside a shelf that shoved the whole hatch off its dock. */
position: relative;
display: inline-flex;
align-items: center;
gap: 10px;
@@ -978,7 +979,6 @@ label.toggle-switch {
font-weight: 500;
color: var(--fg);
}
.admin-modal .toggle-stack > label.toggle-switch,
.toggle-stack > label.toggle-switch {
margin: 0;
}
@@ -987,23 +987,15 @@ label.toggle-switch {
* toggle is the first row of a modal (under the h2) or when it lives
* in a dynamically-rendered row that already supplies its own
* spacing (e.g. judge bool settings). */
.admin-modal label.toggle-switch.toggle--flush,
label.toggle-switch.toggle--flush {
margin-top: 0;
}
.admin-modal .toggle-stack,
.toggle-stack {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 16px;
}
/* Hidden-input + track rules also need the .admin-modal prefix to
* outrank ``.admin-modal input:not([type="hidden"])`` (same
* specificity 0,2,1; that one comes later in source so without the
* bump it wins and forces width:100% on the hidden input, popping it
* back into the layout). */
.admin-modal .toggle-switch input[type="checkbox"],
.toggle-switch input[type="checkbox"] {
/* Visually hidden but still focusable + click-targetable via the
* <label> wrap. Avoids display:none, which would strip the input
@@ -1018,7 +1010,6 @@ label.toggle-switch.toggle--flush {
clip: rect(0 0 0 0);
white-space: nowrap;
}
.admin-modal .toggle-switch .toggle-track,
.toggle-switch .toggle-track {
position: relative;
flex: 0 0 auto;
@@ -1039,7 +1030,6 @@ label.toggle-switch.toggle--flush {
background 0.18s ease,
box-shadow 0.18s ease;
}
.admin-modal .toggle-switch .toggle-track::before,
.toggle-switch .toggle-track::before {
content: "";
position: absolute;
@@ -1051,21 +1041,17 @@ label.toggle-switch.toggle--flush {
border-radius: 50%;
transition: transform 0.18s ease;
}
.admin-modal .toggle-switch input:checked + .toggle-track,
.toggle-switch input:checked + .toggle-track {
background: var(--accent);
box-shadow: none;
}
.admin-modal .toggle-switch input:checked + .toggle-track::before,
.toggle-switch input:checked + .toggle-track::before {
transform: translateX(18px);
background: var(--bg);
}
.admin-modal .toggle-switch input:focus-visible + .toggle-track,
.toggle-switch input:focus-visible + .toggle-track {
box-shadow: 0 0 0 3px var(--accent-dim);
}
.admin-modal .toggle-switch input:disabled + .toggle-track,
.toggle-switch input:disabled + .toggle-track {
opacity: 0.4;
}
@@ -1086,21 +1072,6 @@ label.toggle-switch.toggle--flush {
color: var(--fg-dim);
}
/* Hair divider for separating conceptually-grouped toggles inside a
* stack used between the lone "Enabled" toggle and the paired
* Reasoning toggles in the Add Model modal so the grouping reads
* without needing a subheading or indent. Margin: 0 because the
* .toggle-stack flex container already supplies a 10px gap on each
* side; adding a margin on top would visually separate the divider
* twice. */
.admin-modal hr.toggle-group-divider,
hr.toggle-group-divider {
border: 0;
border-top: 1px solid var(--border);
margin: 0;
width: 100%;
}
/* Segmented option list vertical card group for radio choices that
* benefit from a strong selected-state highlight. The native
* ``<input type="radio">`` is visually hidden but stays focusable;
@@ -1119,7 +1090,6 @@ hr.toggle-group-divider {
* ...
* </div>
*/
.admin-modal .segmented-control,
.segmented-control {
display: flex;
flex-direction: column;
@@ -1129,8 +1099,17 @@ hr.toggle-group-divider {
background: var(--bg);
margin-top: 8px;
}
.admin-modal .segmented-option,
/* Doubled with .sh-body so the cards survive the label micro-cadence
(.sh-body label in the later-loaded hatch.css ties bare .segmented-option
at 0,1,1 and strips the flex layout the indicator collapses onto the
first letters and unselected rows lose their dot entirely). */
.sh-body label.segmented-option,
.segmented-option {
/* The containing block for the visually-hidden abspos radio below same
anchor label.toggle-switch and .sh-body label.cap carry: an unanchored
input sits at its static position outside the .sh-body scroller and
focus-scrolls the wrong ancestor when the radiogroup is below the fold. */
position: relative;
display: flex;
align-items: center;
gap: 10px;
@@ -1220,28 +1199,10 @@ hr.toggle-group-divider {
color: var(--accent);
}
/* Admin modals */
.admin-modal {
background: var(--bg-surface);
border: 1px solid var(--border-strong);
border-radius: var(--radius);
padding: 32px;
width: 380px;
max-width: 90vw;
max-height: 85vh;
overflow-y: auto;
box-shadow:
0 24px 48px -12px rgba(0, 0, 0, 0.5),
0 0 80px -20px var(--accent-dim);
position: relative;
}
.admin-modal.admin-modal-wide {
width: 820px;
}
@media (max-width: 700px) {
.admin-modal.admin-modal-wide {
width: auto;
}
/* Narrow PANE: the MCP-detail two-column read-out stacks (container query
a tight split is narrow on a wide viewport, same rule as the shelf's
sheet mode). */
@container pane (max-width: 700px) {
.modal-columns {
grid-template-columns: 1fr;
gap: 20px 0;
@@ -1256,89 +1217,6 @@ hr.toggle-group-divider {
padding-left: 0;
}
}
.admin-modal::before {
content: "";
position: absolute;
top: 0;
left: 20%;
right: 20%;
height: 2px;
background: linear-gradient(90deg, transparent, var(--accent), transparent);
border-radius: 1px;
z-index: 1;
pointer-events: none;
}
.admin-modal h2 {
font-family: var(--font-ui);
font-size: 15px;
font-weight: 700;
color: var(--accent);
margin-bottom: 16px;
letter-spacing: 0.02em;
}
.admin-modal label {
display: block;
font-family: var(--font-ui);
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--fg-dim);
margin-bottom: 5px;
margin-top: 12px;
}
.admin-modal label:first-of-type {
margin-top: 0;
}
.admin-modal input:not([type="hidden"]),
.admin-modal select,
.admin-modal textarea {
width: 100%;
padding: 9px 12px;
background: var(--bg);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
color: var(--fg);
font: inherit;
font-size: 13px;
transition:
border-color 0.15s,
box-shadow 0.15s;
}
.admin-modal input:focus,
.admin-modal select:focus,
.admin-modal textarea:focus {
border-color: var(--accent);
outline: none;
box-shadow: 0 0 0 3px var(--accent-dim);
}
.admin-modal input:disabled,
.admin-modal select:disabled,
.admin-modal textarea:disabled {
opacity: 0.55;
cursor: not-allowed;
background: var(--bg-highlight);
border-color: var(--border);
color: var(--fg-dim);
}
.admin-modal input::placeholder,
.admin-modal textarea::placeholder {
color: var(--fg-dim);
opacity: 0.6;
}
.admin-modal textarea {
resize: vertical;
min-height: 40px;
}
.admin-modal [role="alert"] {
display: none;
color: var(--red);
font-size: 12px;
margin-bottom: 8px;
}
.admin-modal [role="alert"].is-visible {
display: block;
}
.admin-inline-add {
background: none;
@@ -1432,61 +1310,10 @@ hr.toggle-group-divider {
}
}
.admin-details {
margin-top: 12px;
border: 1px solid var(--border);
border-radius: 6px;
padding: 0 12px;
}
.admin-details[open] {
padding-bottom: 12px;
}
.admin-details summary {
cursor: pointer;
padding: 10px 0;
font-family: var(--font-ui);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--fg-dim);
display: flex;
justify-content: space-between;
align-items: center;
list-style: none;
}
.admin-details summary::-webkit-details-marker {
display: none;
}
.admin-details summary::after {
content: "\25B8";
font-size: 11px;
color: var(--fg-dim);
transition: transform 0.15s ease;
}
.admin-details[open] summary::after {
transform: rotate(90deg);
}
.admin-details summary .label-hint {
font-weight: 400;
}
.admin-details label:first-of-type {
margin-top: 4px;
}
/* ==========================================================================
Skill Spec Modal two-column manifest layout
Skill shelf two-column spec body
Left: Identity / Manifest / Deployment | Right: Skill Content
========================================================================== */
.admin-modal-skill {
padding: 28px 28px 24px;
}
/* Reserve space for the absolute-positioned lock button (top-right) so a
long title can never collide with it. */
.admin-modal-skill > h2 {
padding-right: 44px;
}
.skill-spec-body {
display: grid;
grid-template-columns: 1fr 1.55fr;
@@ -1503,6 +1330,13 @@ hr.toggle-group-divider {
padding-left: 22px;
display: flex;
flex-direction: column;
/* The body is the scroll container; pinning the content column keeps the
SKILL.md pane visible while the (taller) meta column scrolls the whole
point of the two-column split. */
position: sticky;
top: 0;
align-self: start;
max-height: 100%;
}
.skill-spec-section {
@@ -1566,10 +1400,10 @@ h3.skill-spec-heading {
flex-direction: column;
}
/* Chained `textarea.` so the rule beats `.admin-modal textarea` (0,1,1) on
source order without that bump, min-height: 220px loses to the modal's
default min-height: 40px and the spec content textarea renders short. */
textarea.skill-content-area {
/* Scoped under `.sh-body` so the rule beats hatch.css's `.sh-body textarea`
(0,1,1, later in source) without that bump, the shelf's `font: inherit`
+ min-height: 64px would flatten the mono face and the 220px floor. */
.sh-body textarea.skill-content-area {
flex: 1;
min-height: 220px;
font-family: var(--font-mono);
@@ -1577,29 +1411,6 @@ textarea.skill-content-area {
line-height: 1.65;
}
.skill-vars-row {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
min-height: 18px;
}
.skill-vars-label {
font-family: var(--font-ui);
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--fg-dim);
white-space: nowrap;
flex-shrink: 0;
}
.skill-vars-display {
font-size: 11px;
}
.skill-config-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
@@ -1610,7 +1421,19 @@ textarea.skill-content-area {
min-width: 0;
}
/* Origin badge — shown for remotely installed (readonly) skills */
/* Light theme: the off-state track's --bg fill is the same near-white as
the panel (~1.2:1) give it real ink so on/off reads without hunting the
thumb. Dark keeps the recessed --bg + inset ring (visible there). */
[data-theme="light"] .toggle-switch .toggle-track {
background: color-mix(in srgb, var(--ink) 18%, transparent);
box-shadow: none;
}
[data-theme="light"] .toggle-switch input:checked + .toggle-track {
background: var(--accent);
}
/* Origin badge provenance chip in the skill shelf's foot meta lane
(installed/customized skills). Sized to the 11px foot strip. */
.skill-origin-badge {
display: inline-flex;
align-items: center;
@@ -1621,11 +1444,10 @@ textarea.skill-content-area {
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--cyan);
background: rgba(103, 232, 249, 0.07);
border: 1px solid rgba(103, 232, 249, 0.18);
background: color-mix(in srgb, var(--cyan) 15%, transparent);
border: 1px solid color-mix(in srgb, var(--cyan) 35%, transparent);
border-radius: var(--radius-sm);
padding: 5px 10px;
margin-bottom: 14px;
padding: 2px 7px;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
@@ -1637,7 +1459,29 @@ textarea.skill-content-area {
flex-shrink: 0;
}
@media (max-width: 700px) {
/* Head lock affordance on the skill shelf the sh-x ghost recipe minus its
right-edge margin compensation (the lock sits mid-strip, left of the
designation plate). Text-variant keeps the glyph monochrome. */
#skill-shelf .skl-lock {
margin: -4px 0;
font-variant-emoji: text;
}
/* Destructive variant of the hatch quiet button text-weight danger for
foot actions that destroy (memory-detail Delete), keeping the filled
err treatment reserved for confirm-dialog primaries. Doubled class so
the rule beats the later-loaded hatch.css `.sh-btn--quiet` color. */
.sh-btn.sh-btn--quiet-danger {
color: var(--err);
}
.sh-btn.sh-btn--quiet-danger:hover {
background: color-mix(in srgb, var(--err) 10%, transparent);
color: var(--err);
}
/* Narrow pane (mobile OR a tight split): single column, matching the
shelf's own bottom-sheet container breakpoint in hatch.css. */
@container pane (max-width: 700px) {
.skill-spec-body {
grid-template-columns: 1fr;
}
@@ -1654,169 +1498,6 @@ textarea.skill-content-area {
.skill-config-grid {
grid-template-columns: 1fr 1fr;
}
/* Touch target: 44×44 minimum on mobile (WCAG 2.5.5 / Apple HIG). */
.skill-lock-btn {
width: 44px;
height: 44px;
top: 8px;
right: 8px;
}
.admin-modal-skill > h2 {
padding-right: 56px;
}
}
.modal-buttons {
display: flex;
gap: 10px;
margin-top: 20px;
}
.modal-cancel {
flex: 1;
padding: 9px;
background: var(--bg-highlight);
color: var(--fg);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
font: inherit;
font-family: var(--font-ui);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
}
.modal-cancel:hover {
background: var(--bg-elevated);
}
.modal-cancel:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.modal-cancel:disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
.modal-submit {
flex: 1;
padding: 9px;
background: var(--accent);
color: var(--bg);
border: none;
border-radius: var(--radius-sm);
font: inherit;
font-family: var(--font-ui);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: filter 0.15s;
}
.modal-submit:hover {
filter: brightness(1.1);
}
.modal-submit:focus-visible {
/* fg-bright (not accent) modal-submit has an accent background, so
accent-on-accent would be invisible. Cancel/secondary use --accent
because their backgrounds are transparent / highlight. */
outline: 2px solid var(--fg-bright);
outline-offset: 2px;
}
.modal-submit:disabled {
opacity: 0.4;
cursor: not-allowed;
filter: none;
}
/* Lock-icon affordance in the top-right of an installed (readonly) skill's
edit modal clicking detaches the skill from upstream so spec fields
become editable. Reserved for the modal it lives in; not a generic
button class. */
.skill-lock-btn {
position: absolute;
/* top: 18px (not 14px) so the button drops below the modal's accent-line
decoration's visual zone instead of competing with it horizontally. */
top: 18px;
right: 14px;
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
background: transparent;
color: var(--fg);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
font-size: 14px;
/* Render the lock glyph as text where supported (keeps the monochrome
instrument-panel aesthetic instead of a coloured emoji). Browsers
that don't support font-variant-emoji fall back gracefully. */
font-variant-emoji: text;
cursor: pointer;
transition:
background 0.15s,
border-color 0.15s;
z-index: 2;
}
.skill-lock-btn:hover {
background: var(--bg-highlight);
border-color: var(--accent);
color: var(--accent);
}
.skill-lock-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.skill-lock-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
#create-user-overlay,
#create-token-overlay,
#token-created-overlay,
#create-channel-overlay,
#confirm-overlay,
#create-schedule-overlay,
#edit-schedule-overlay,
#schedule-runs-overlay,
#create-role-overlay,
#edit-role-overlay,
#user-roles-overlay,
#create-policy-overlay,
#edit-policy-overlay,
#create-ppolicy-overlay,
#edit-ppolicy-overlay,
#create-template-overlay,
#edit-template-overlay,
#memory-detail-overlay,
#mcp-create-overlay,
#mcp-import-overlay,
#mcp-detail-overlay,
#mcp-install-overlay,
#github-import-overlay,
#model-create-overlay,
#create-hr-overlay,
#edit-hr-overlay,
#create-ogp-overlay,
#edit-ogp-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
display: flex;
align-items: center;
justify-content: center;
z-index: 600;
}
/* Confirm dialogs are launched FROM other overlays (e.g. unlock-skill from
the edit-template modal). They share z-index 600, so DOM order picks the
winner and confirm-overlay is earlier in the DOM, so it would render
underneath. Bump it above the per-feature overlays but keep it below
toasts (z-index 700). */
#confirm-overlay {
z-index: 650;
}
/* Token display (show-once) */
@@ -2456,8 +2137,8 @@ textarea.skill-content-area {
/* Permission section grouping keeps each namespace contiguous so
* the row-flow grid below doesn't slice ``admin.*`` mid-column. The
* caps-styled ``.perm-section-label`` re-anchors the toggles to the
* modal's typographic system (matches ``.admin-modal label``: 10px
* caps, 0.08em letter-spacing, fg-dim). */
* shelf's typographic system (matches ``.sh-body label``: 10px caps,
* 0.08em letter-spacing, fg-dim). */
.perm-section {
margin-top: 12px;
}
@@ -2483,7 +2164,6 @@ textarea.skill-content-area {
* permission name in monospace + lower case (it's an identifier, not
* a heading) overrides the .toggle-label's caps/letter-spacing
* cadence used elsewhere in admin modals. */
.admin-modal .perm-grid label.toggle-switch.perm-toggle,
.perm-grid label.toggle-switch.perm-toggle {
margin: 0;
}
@@ -2501,14 +2181,12 @@ textarea.skill-content-area {
* human-readable role display names so we keep ui-font + caps-on so
* the stack reads like a settings list. Only override the layout
* margin (the modal already provides outer padding). */
.admin-modal .user-roles-list,
.user-roles-list {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 8px;
}
.admin-modal .user-roles-list label.toggle-switch.user-role-toggle,
.user-roles-list label.toggle-switch.user-role-toggle {
margin: 0;
}
@@ -3433,6 +3111,10 @@ textarea.skill-content-area {
.mcp-install-source-group {
margin-bottom: 14px;
}
/* Doubled with .sh-body the fourth label-rooted shelf component (after
toggle-switch / cap / segmented-option) that must out-rank the hatch.css
label micro-cadence at 0,1,1. */
.sh-body label.mcp-install-source-label,
.mcp-install-source-label {
display: flex;
align-items: center;
@@ -3749,19 +3431,6 @@ textarea.skill-content-area {
outline-offset: 1px;
}
/* Modal section divider for field groups */
.modal-section-divider {
font-family: var(--font-ui);
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--fg-dim);
margin: 16px 0 4px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
/* Model source badge */
.scope-db {
color: var(--blue);
@@ -3808,14 +3477,7 @@ textarea.skill-content-area {
#view-admin {
animation: none;
}
.admin-action-btn,
.modal-cancel,
.modal-submit,
.skill-lock-btn {
transition: none;
}
.admin-modal input,
.admin-modal select {
.admin-action-btn {
transition: none;
}
.mcp-status-dot.connecting {
@@ -3825,9 +3487,6 @@ textarea.skill-content-area {
.admin-expand-indicator {
transition: none;
}
.admin-details summary::after {
transition: none;
}
.mcp-view-btn,
.mcp-reg-card,
.mcp-install-btn,
+13 -13
View File
@@ -98,13 +98,17 @@ def load_verdict_indexes(
return verdicts_by_call_id, assessments_by_call_id
def build_verdict_payload(vrow: dict[str, Any]) -> dict[str, Any] | None:
def build_verdict_payload(vrow: dict[str, Any]) -> dict[str, Any]:
"""Project a stored ``intent_verdicts`` row into the wire shape.
Returns ``None`` when the verdict is the unflagged baseline
(``risk_level == "none"``) the client's ``renderVerdictBadge``
helper would suppress those anyway, so skipping at the wire layer
keeps the payload tight on long workstreams.
Ships every row, including the unflagged baseline (``risk_level ==
"none"``) the live path renders a badge for every verdict the
judge delivers (``buildConvVerdict`` has no risk filter), so the
replay payload must carry the same set or rehydration silently
"loses" verdicts the operator watched land live. An earlier
revision suppressed ``none`` rows here on the assumption the
client filtered them anyway; it never did, and the asymmetry
surfaced as benign verdicts vanishing after a restart.
Drops ``call_id`` and ``func_name`` from the wire payload they're
already carried on the parent ``tc.id`` / ``tc.name`` fields.
@@ -115,10 +119,8 @@ def build_verdict_payload(vrow: dict[str, Any]) -> dict[str, Any] | None:
badge can render `` llm:claude-haiku-4`` on history-only batches
rather than the bare `` llm`` label.
"""
if (vrow.get("risk_level") or "none") == "none":
return None
payload: dict[str, Any] = {
"risk_level": vrow.get("risk_level", "medium"),
"risk_level": vrow.get("risk_level") or "none",
"recommendation": vrow.get("recommendation", "review"),
"confidence": vrow.get("confidence", 0.0),
"intent_summary": vrow.get("intent_summary", ""),
@@ -190,17 +192,15 @@ def decorate_tool_call(
either the OpenAI-nested ``{id, function: {name, arguments}}`` shape
what ``decorate_history_messages`` passes from the REST ``/history``
pipeline or a flattened ``{id, name, arguments}`` shape. No-ops
cleanly when the call_id has no matching row (unflagged tools stay
clean).
cleanly when the call_id has no matching row (tools the judge never
evaluated stay clean).
"""
call_id = tc.get("id", "") or ""
if not call_id:
return
vrow = verdicts_by_call_id.get(call_id)
if vrow is not None:
verdict = build_verdict_payload(vrow)
if verdict is not None:
tc["verdict"] = verdict
tc["verdict"] = build_verdict_payload(vrow)
slot = assessments_by_call_id.get(call_id)
if slot is not None:
assessment = build_merged_output_assessment_payload(slot)
+33 -15
View File
@@ -94,7 +94,12 @@ class JudgeConfig:
output_guard_model: str = "" # alias for the LLM stage; empty = inherit session model
output_guard_llm_timeout: float = 30.0 # wall-clock budget for the LLM stage
redact_secrets: bool = True
cancel_on_approval: bool = False # True = abort remaining items on user approval
# True = the approval gate's resolution aborts remaining evaluations
# (saves inference; undone items degrade to ``llm_fallback`` verdicts
# carrying the heuristic content).
# False (default) = the daemon runs every item to completion; only a
# generation supersede (next batch) or session close aborts it.
cancel_on_approval: bool = False
# ---------------------------------------------------------------------------
@@ -984,10 +989,14 @@ class IntentJudge:
``func_args``, ``approval_label``, ``call_id``).
messages: Conversation history (OpenAI message format).
callback: Called with each LLM verdict (or timeout/error fallback).
cancel_event: When set, the daemon judge thread abandons
remaining work. Callers should set this after the user
has already made an approval decision so the judge does
not keep consuming inference resources.
cancel_event: Unconditional abort signal when set, the
daemon abandons remaining work and delivers
``llm_fallback`` verdicts (heuristic-derived) for every
undone item. The caller
owns the firing policy: ChatSession fires it when a
newer batch supersedes this generation, on session
close, and only when ``cancel_on_approval`` is
enabled as soon as the approval gate resolves.
Returns:
List of heuristic verdicts (one per item), available immediately.
@@ -1031,21 +1040,30 @@ class IntentJudge:
) -> None:
"""Daemon thread: run LLM judge for each item and invoke callback.
When ``cancel_on_approval`` is True, remaining evaluations are
aborted as soon as the user approves/denies. When False (default),
every evaluation runs to completion so all verdicts are delivered.
``cancel_event`` is an unconditional abort signal: once it fires,
in-flight work stops and every remaining item is delivered as an
``llm_fallback`` verdict the heuristic verdict's content,
relabeled (each call still gets exactly one
verdict Smart Approvals and the advisory UI both rely on the
full set arriving). Which actor fires the event is the CALLER's
policy, not this loop's: ChatSession fires it at approval
resolution only when ``cancel_on_approval`` is enabled, and
always when the next batch supersedes this generation or the
session closes. With ``cancel_on_approval=False`` (default) and
no supersede, every evaluation runs to completion so all
verdicts are delivered.
"""
client = self._create_client()
executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="judge-api")
try:
for idx, (item, h_verdict) in enumerate(zip(items, heuristic_verdicts, strict=True)):
if cancel_event and cancel_event.is_set() and self._config.cancel_on_approval:
if cancel_event and cancel_event.is_set():
log.info("judge.cancelled", remaining=len(items) - idx)
self._deliver_fallbacks(
items[idx:],
heuristic_verdicts[idx:],
callback,
"judge cancelled by user approval",
"judge cancelled before evaluating this call",
)
return
try:
@@ -1087,15 +1105,15 @@ class IntentJudge:
call_id=fallback.call_id,
)
callback(fallback)
# After delivering this item's verdict, check if we should
# abort remaining items due to user approval.
if cancel_event and cancel_event.is_set() and self._config.cancel_on_approval:
# After delivering this item's verdict, check whether the
# abort signal fired while we were evaluating it.
if cancel_event and cancel_event.is_set():
log.info("judge.cancelled.after_eval", call_id=item.get("call_id", ""))
self._deliver_fallbacks(
items[idx + 1 :],
heuristic_verdicts[idx + 1 :],
callback,
"judge cancelled by user approval",
"judge cancelled before evaluating this call",
)
return
except _ExecutorPoisonedError:
@@ -1130,7 +1148,7 @@ class IntentJudge:
callback: Callable[[IntentVerdict], None],
reason: str,
) -> None:
"""Deliver heuristic fallback verdicts for items the judge didn't complete."""
"""Deliver ``llm_fallback`` verdicts (heuristic content) for items the judge didn't complete."""
for _item, h_verdict in zip(remaining_items, remaining_verdicts, strict=True):
fallback = IntentVerdict(
verdict_id=h_verdict.verdict_id,
+80 -4
View File
@@ -57,7 +57,7 @@ from turnstone.core.mcp_oauth import (
)
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable
from collections.abc import Awaitable, Callable, Coroutine
log = get_logger("turnstone.mcp")
@@ -607,6 +607,13 @@ class MCPClientManager:
# static-only deployments).
self._user_pool_eviction_task: asyncio.Task[None] | None = None
# Strong references to fire-and-forget background tasks (catalog
# refreshes etc.). ``create_task`` alone keeps only a weak ref — an
# untracked task can be GC'd mid-flight, and its exception surfaces
# as "Task exception was never retrieved" at GC time instead of
# being logged where it happened. See ``_spawn_background``.
self._background_tasks: set[asyncio.Task[Any]] = set()
def _ensure_static_state(self, name: str) -> StaticServerState:
"""Get or create the StaticServerState for ``name``.
@@ -2712,6 +2719,30 @@ class MCPClientManager:
def shutdown(self) -> None:
"""Close all MCP sessions and stop the background loop."""
# Cancel tracked background tasks (catalog refreshes etc.) FIRST —
# they are pure auxiliaries, and draining them up front means the
# stack teardown below can't race an in-flight refresh. Submitted
# whenever a loop exists — NOT gated on a main-thread truthiness
# check of ``_background_tasks``: a spawn queued via
# call_soon_threadsafe may not have reached the set yet, but ready
# callbacks run in FIFO order, so by the time the drain coroutine
# snapshots the set ON the loop, every earlier-queued spawn has
# landed. ``is_running()`` guard: on a stopped loop nothing can
# execute the drain — submitting would just stall on the future.
if self._loop is not None and self._loop.is_running():
async def _cancel_background() -> None:
tasks = list(self._background_tasks)
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
future = asyncio.run_coroutine_threadsafe(_cancel_background(), self._loop)
try:
future.result(timeout=10)
except Exception:
log.debug("Error cancelling MCP background tasks", exc_info=True)
# Cancel the pool eviction task, then close all pool entries
# before tearing down static-path state. Both run on the
# mcp-loop so dispatcher coroutines can't race them.
@@ -2767,8 +2798,21 @@ class MCPClientManager:
self._loop.call_soon_threadsafe(self._loop.stop)
if self._thread:
self._thread.join(timeout=5)
if self._thread.is_alive():
# Closing a still-running loop raises; the daemon thread dies
# with the process, so leaving the loop open is the lesser
# evil. Loud because a stuck loop thread is itself a bug.
log.warning("MCP loop thread did not stop within 5s; loop left open")
else:
if self._loop is not None:
self._loop.close()
self._loop = None
self._thread = None
# When no thread was started (tests wire ``_loop`` directly) the loop
# is not ours to close — the stop above is all the owner needs.
# Clear all state
self._background_tasks.clear()
self._static_servers.clear()
self._db_managed.clear()
self._tools = []
@@ -3327,6 +3371,33 @@ class MCPClientManager:
# broken the first failure re-trips the circuit immediately.
self._circuit_open_until.pop(server_name, None)
def _spawn_background(self, coro: Coroutine[Any, Any, Any], label: str) -> asyncio.Task[Any]:
"""Schedule *coro* as a tracked background task (loop thread only).
Holds a strong reference until completion and retrieves the task's
outcome in a done-callback: failures are logged once, here, at
warning never deferred to garbage collection, where they surface
as "Task exception was never retrieved" on whatever stream happens
to be attached at the time. ``shutdown()`` cancels anything still
tracked before stopping the loop.
"""
task = asyncio.create_task(coro)
self._background_tasks.add(task)
def _done(t: asyncio.Task[Any]) -> None:
try:
if not t.cancelled():
exc = t.exception()
if exc is not None:
log.warning("MCP background %s failed", label, exc_info=exc)
finally:
# Discard LAST so set-emptiness means "done AND reported" —
# a watcher keying on emptiness must never race the warning.
self._background_tasks.discard(t)
task.add_done_callback(_done)
return task
def _cb_auto_reconnect(self, server_name: str) -> Any:
"""Attempt reconnection for a disconnected server during half-open probe.
@@ -3355,13 +3426,18 @@ class MCPClientManager:
# Schedule catalog refresh on the loop without blocking the caller.
# The reconnected session is valid for the imminent dispatch; catalog
# drift will be reconciled on the loop in the background.
# drift will be reconciled on the loop in the background. The task is
# tracked: a refresh FAILURE is logged by the done-callback — this
# except only covers the scheduling itself.
def _schedule_refresh() -> None:
try:
asyncio.create_task(self._refresh_server(server_name))
self._spawn_background(
self._refresh_server(server_name),
f"catalog refresh after reconnect for '{server_name}'",
)
except Exception:
log.warning(
"Catalog refresh after reconnect failed for '%s'",
"Scheduling catalog refresh after reconnect failed for '%s'",
server_name,
exc_info=True,
)
+8
View File
@@ -817,6 +817,14 @@ def probe_model_endpoint(
if known is not None:
result["context_window"] = known["context_window"]
result["server_type"] = "xai"
elif provider == "anthropic-compatible":
# No static table for local models; vLLM exposes max_model_len
# as an extra field the Anthropic SDK preserves (extra="allow").
if inspect_obj is not None:
max_len = inspect_obj.model_dump().get("max_model_len")
if isinstance(max_len, int) and max_len > 0:
result["context_window"] = max_len
result["server_type"] = "anthropic-compatible"
else:
# OpenAI-compatible path
_detect_openai_compat(result, inspect_obj, inspect_id, base_url)
+39 -8
View File
@@ -46,6 +46,7 @@ _openai_provider = OpenAIResponsesProvider()
_openai_compat_provider = OpenAIChatCompletionsProvider()
_xai_provider = XAIProvider()
_anthropic_provider: LLMProvider | None = None
_anthropic_compat_provider: LLMProvider | None = None
_google_provider: LLMProvider | None = None
@@ -67,8 +68,13 @@ def create_provider(
endpoints like Mistral cloud, or local servers that expose the
Responses surface).
Ignored for non-OpenAI providers. ``provider_name="openai"`` always
uses the Responses API regardless of *api_surface*.
Ignored for non-OpenAI providers both Anthropic lanes
(``"anthropic"`` and ``"anthropic-compatible"``) talk to the
Messages API regardless of *api_surface*.
``provider_name="anthropic-compatible"`` returns the Anthropic
adapter in compat mode (local servers exposing ``/v1/messages``,
e.g. vLLM). ``provider_name="openai"`` always uses the Responses
API regardless of *api_surface*.
Note: the ``OpenAIResponsesProvider`` singleton is reused for both
cloud OpenAI and ``openai-compatible`` + responses, so its
@@ -77,7 +83,7 @@ def create_provider(
must read ``ModelConfig.provider`` and ``server_compat["api_surface"]``
rather than ``provider.provider_name``.
"""
global _anthropic_provider, _google_provider # noqa: PLW0603
global _anthropic_provider, _anthropic_compat_provider, _google_provider # noqa: PLW0603
if provider_name == "openai":
return _openai_provider
if provider_name == "openai-compatible":
@@ -98,6 +104,13 @@ def create_provider(
_anthropic_provider = AnthropicProvider()
return _anthropic_provider
if provider_name == "anthropic-compatible":
with _provider_lock:
if _anthropic_compat_provider is None:
from turnstone.core.providers._anthropic import AnthropicProvider
_anthropic_compat_provider = AnthropicProvider(compat=True)
return _anthropic_compat_provider
if provider_name == "google":
with _provider_lock:
if _google_provider is None:
@@ -107,7 +120,7 @@ def create_provider(
return _google_provider
raise ValueError(
f"Unknown provider: {provider_name!r}. "
"Supported: openai, anthropic, google, openai-compatible, xai"
"Supported: openai, anthropic, google, openai-compatible, anthropic-compatible, xai"
)
@@ -133,19 +146,36 @@ def create_client(provider_name: str, *, base_url: str, api_key: str) -> Any:
if base_url:
return OpenAI(base_url=base_url, api_key=resolved_key)
return OpenAI(api_key=resolved_key)
if provider_name == "anthropic":
if provider_name in ("anthropic", "anthropic-compatible"):
from turnstone.core.providers._anthropic import _ensure_anthropic
anthropic = _ensure_anthropic()
kwargs: dict[str, str] = {}
if resolved_key is not None:
kwargs["api_key"] = resolved_key
if provider_name == "anthropic-compatible":
# The lane targets local /v1/messages servers; without a
# base_url the SDK would default to https://api.anthropic.com
# and send compat-shaped requests to the commercial API.
if not base_url:
raise ValueError(
"anthropic-compatible requires base_url (the server root, "
"e.g. http://your-vllm-host:8000)"
)
# The Anthropic SDK appends /v1/... to base_url, so a
# /v1-suffixed URL (the openai-compatible convention) would
# request /v1/v1/messages and 404. Tolerate the suffix.
# Keep the verbatim value when stripping would empty it
# (base_url of exactly "/v1") so the typo still fails loudly
# instead of silently retargeting the SDK's prod default.
stripped = base_url.rstrip("/").removesuffix("/v1")
base_url = stripped or base_url
if base_url and base_url != "https://api.anthropic.com":
kwargs["base_url"] = base_url
return anthropic.Anthropic(**kwargs)
raise ValueError(
f"Unknown provider: {provider_name!r}. "
"Supported: openai, anthropic, google, openai-compatible, xai"
"Supported: openai, anthropic, google, openai-compatible, anthropic-compatible, xai"
)
@@ -153,11 +183,12 @@ def lookup_model_capabilities(provider: str, model: str) -> dict[str, Any] | Non
"""Return static capabilities for a known model, or ``None`` if unknown.
The returned dict has JSON-friendly values (tuples converted to lists).
Returns ``None`` for ``openai-compatible`` (no static table for local models).
Returns ``None`` for ``openai-compatible`` and ``anthropic-compatible``
(no static table for local models).
"""
import dataclasses
if provider == "openai-compatible":
if provider in ("openai-compatible", "anthropic-compatible"):
return None
prov = create_provider(provider)
caps = prov.get_capabilities(model)
+46 -2
View File
@@ -71,6 +71,11 @@ _WEB_SEARCH_TOOL_TYPE = "web_search_20250305"
# Tool search: server-side BM25 tool discovery for deferred tools
_TOOL_SEARCH_TOOL_TYPE = "tool_search_tool_bm25"
# extra_params keys consumed internally (``_reasoning_params``) — never
# forwarded to the wire ``extra_body``. Keeps real-Anthropic request
# bodies byte-identical when a caller threads thinking overrides.
_INTERNAL_EXTRA_PARAMS = frozenset({"thinking_budget_tokens"})
# -- model capabilities -------------------------------------------------------
_ANTHROPIC_DEFAULT = ModelCapabilities(
@@ -83,6 +88,27 @@ _ANTHROPIC_DEFAULT = ModelCapabilities(
supports_reasoning_replay=True,
)
# Anthropic-compatible local servers (vLLM's /v1/messages endpoint):
# token_param must be "max_tokens" (the only token param the endpoint
# accepts); the "thinking" request param is not consumed by vLLM — the
# reasoning toggle rides chat_template_kwargs via extra_body, so
# thinking_mode stays "none"; supports_reasoning_replay stays True even
# so, because the endpoint emits and round-trips thinking blocks whenever
# the chat template enables reasoning (the request param is simply not
# the switch); native web_search / tool_search server-tool types 400 on
# vLLM (tools require input_schema); vision is opt-in per model.
# supports_temperature stays True via the dataclass default.
_ANTHROPIC_COMPAT_DEFAULT = ModelCapabilities(
context_window=200000,
max_output_tokens=64000,
token_param="max_tokens",
thinking_mode="none",
supports_web_search=False,
supports_tool_search=False,
supports_vision=False,
supports_reasoning_replay=True,
)
_ANTHROPIC_CAPABILITIES: dict[str, ModelCapabilities] = {
# Fable 5: same wire surface as opus-4-8 (adaptive-only thinking, no
# sampling params, prefill rejected) with one extra constraint — an
@@ -239,13 +265,24 @@ ANTHROPIC_REASONING_BLOCK_TYPES = frozenset({"thinking", "redacted_thinking"})
class AnthropicProvider:
"""Provider for Anthropic's Messages API with native streaming."""
"""Provider for Anthropic's Messages API with native streaming.
``compat=True`` serves Anthropic-compatible local servers (vLLM's
``/v1/messages``): same wire translation, but capabilities come from
``_ANTHROPIC_COMPAT_DEFAULT`` for every model the static Claude
table never applies to local checkpoints.
"""
def __init__(self, *, compat: bool = False) -> None:
self._compat = compat
@property
def provider_name(self) -> str:
return "anthropic"
return "anthropic-compatible" if self._compat else "anthropic"
def get_capabilities(self, model: str) -> ModelCapabilities:
if self._compat:
return _ANTHROPIC_COMPAT_DEFAULT
return _lookup_capabilities(model, _ANTHROPIC_CAPABILITIES, _ANTHROPIC_DEFAULT)
# -- web search tool injection -------------------------------------------
@@ -352,6 +389,13 @@ class AnthropicProvider:
if effort:
kwargs["output_config"] = {"effort": effort}
# Operator server_compat extra_body overrides (e.g. chat_template_kwargs
# for anthropic-compatible local servers) ride the SDK's extra_body.
if extra_params:
wire_extra = {k: v for k, v in extra_params.items() if k not in _INTERNAL_EXTRA_PARAMS}
if wire_extra:
kwargs["extra_body"] = wire_extra
return kwargs
# -- message conversion --------------------------------------------------
+114 -20
View File
@@ -86,6 +86,7 @@ from turnstone.core.memory import (
search_visible_structured_memories,
set_message_attachments,
set_workstream_alias,
touch_structured_memories,
update_workstream_title,
)
from turnstone.core.memory_relevance import (
@@ -1031,6 +1032,10 @@ class ChatSession:
# tool results) and the recent-context string is identical across
# them. Invalidated on user-turn append and on memory write/delete.
self._mem_search_cache: dict[tuple[str, str, int], list[dict[str, str]]] = {}
# Per-turn dedup for composition touches: ``_init_system_messages`` runs
# many times within a turn, so the injected set is touched at most once
# per memory per turn. Cleared alongside the search cache.
self._touched_memory_keys: set[tuple[str, str, str]] = set()
self._ws_id = ws_id or uuid.uuid4().hex
self._title_generated = False
self._read_files: set[str] = set()
@@ -2873,6 +2878,9 @@ class ChatSession:
candidates=len(visible_mems),
injected=len(relevant),
)
# Access metadata tracks what the model actually saw — touch the
# injected top-k, not the candidate pool.
self._touch_injected_memories(relevant)
if relevant:
dev_parts.append("")
dev_parts.append(build_memory_context(relevant))
@@ -3127,7 +3135,10 @@ class ChatSession:
Forwards operator-supplied ``server_compat["extra_body"]`` overrides
(``skip_special_tokens``, ``reasoning_format``, or explicit
``chat_template_kwargs``) to the OpenAI SDK ``extra_body``. Operators
``chat_template_kwargs``) to the OpenAI SDK ``extra_body`` on the
OpenAI-shaped lanes, and to the Anthropic SDK ``extra_body`` on the
anthropic-compatible lane (the channel for vLLM's
``chat_template_kwargs`` reasoning toggle). Operators
running gpt-oss-style local templates that consume ``reasoning_effort``
from ``chat_template_kwargs`` should set it explicitly under
``server_compat["extra_body"]["chat_template_kwargs"]``.
@@ -3143,9 +3154,11 @@ class ChatSession:
from turnstone.core.server_compat import merge_server_compat
prov = provider or self._provider
# Only OpenAI-shaped providers consume extra_body. Anthropic/Google
# have their own param paths handled inside their providers.
if prov.provider_name not in ("openai", "openai-compatible"):
# extra_body consumers: the OpenAI-shaped providers, plus the
# anthropic-compatible lane (server_compat extra_body rides the
# Anthropic SDK's extra_body). Real Anthropic and Google keep
# their own param paths handled inside their providers.
if prov.provider_name not in ("openai", "openai-compatible", "anthropic-compatible"):
return None
extra = merge_server_compat(None, self._get_server_compat(model_alias))
return extra or None
@@ -5276,8 +5289,15 @@ class ChatSession:
async LLM judge that delivers final verdicts via UI callback.
Returns a cancel event that, when set, tells the daemon judge
thread to abandon remaining work. Callers should set this
after the user has made an approval decision.
thread to abandon remaining work (each undone item degrades to
an ``llm_fallback`` verdict carrying the heuristic content).
``_execute_tools`` fires it
unconditionally when the next batch supersedes this generation,
``close()`` fires it on session teardown, and the approval
gate's ``finally`` fires it on decision only when
``judge.cancel_on_approval`` is enabled the default leaves
the daemon running to completion so every call gets a real
LLM verdict for the audit trail.
"""
judge = self._ensure_judge()
if not judge:
@@ -5433,18 +5453,32 @@ class ChatSession:
def _on_verdict(verdict: object) -> None:
"""Callback from the daemon judge thread.
Drop the verdict when a newer turn has replaced this judge
generation. With ``cancel_on_approval=False`` (the default) the
prior turn's daemon runs to completion and would otherwise write a
stale verdict keyed only by ``call_id`` into the freshly-reset
``_llm_verdicts`` cache; a model that reuses a ``call_id`` across
turns could then ride that stale ``approve`` to a wrongful Smart
Approval of a *different* call. Identity-comparing the live
generation closes that without affecting same-turn late delivery
(``cancel_on_approval=False`` still streams this turn's verdicts,
since the session event still points at this ``cancel_event``).
Withhold the verdict from the live surfaces when a newer turn has
replaced this judge generation. With ``cancel_on_approval=False``
(the default) the prior turn's daemon runs to completion and would
otherwise write a stale verdict keyed only by ``call_id`` into
the freshly-reset ``_llm_verdicts`` cache; a model that reuses a
``call_id`` across turns could then ride that stale ``approve`` to
a wrongful Smart Approval of a *different* call. Identity-
comparing the live generation closes that without affecting
same-turn late delivery (``cancel_on_approval=False`` still
streams this turn's verdicts, since the session event still
points at this ``cancel_event``).
Superseded verdicts still reach the audit table via the UI's
``on_superseded_intent_verdict`` (persist-only, duck-typed
display-only UIs like the CLI don't define it and skip straight
to the drop). Without that, every judge ruling that landed after
the next turn began left ``intent_verdicts`` claiming the judge
never answered.
"""
if self._judge_cancel_event is not cancel_event:
persist_only = getattr(self.ui, "on_superseded_intent_verdict", None)
if persist_only is not None:
try:
persist_only(verdict.to_dict()) # type: ignore[attr-defined]
except Exception:
log.debug("judge.superseded_verdict_persist_failed", exc_info=True)
return
try:
self.ui.on_intent_verdict(verdict.to_dict()) # type: ignore[attr-defined]
@@ -6073,8 +6107,23 @@ class ChatSession:
try:
approved, user_feedback = self.ui.approve_tools(items)
finally:
if judge_cancel:
judge_cancel.set() # user decided (or disconnected) — stop judge
# Gate resolution fires the judge's abort signal only when the
# operator opted in: with ``judge.cancel_on_approval`` the daemon
# stops spending inference the moment a decision lands (remaining
# items degrade to ``llm_fallback`` verdicts, heuristic
# content relabeled). With the
# default False the daemon runs every item to completion — the
# contract the setting's help text promises — and late verdicts
# stream + persist through ``_on_verdict``. An unconditional
# set here used to defeat that: ``_evaluate_single`` polls the
# event regardless of config, so every undone item silently
# became a fallback row the instant the gate resolved. A stale
# daemon is still bounded to one batch of real work by the
# unconditional supersede-set at the top of the next batch and
# by ``close()``.
jc_live = self._judge_cfg
if judge_cancel and jc_live and jc_live.cancel_on_approval:
judge_cancel.set()
self._emit_state("running")
if not approved:
# Mark all pending items as denied
@@ -7403,6 +7452,7 @@ class ChatSession:
def _invalidate_memory_cache(self) -> None:
"""Drop the per-turn search cache; call on user-turn append + memory writes."""
self._mem_search_cache.clear()
self._touched_memory_keys.clear()
def _select_memory_candidates(self, context: str) -> tuple[list[dict[str, str]], str]:
"""Pick the candidate set fed into BM25 ranking.
@@ -7440,6 +7490,38 @@ class ChatSession:
return extra, "recency"
return search_hits + extra, ("union" if extra else "search")
@staticmethod
def _memory_keys(rows: list[dict[str, str]]) -> list[tuple[str, str, str]]:
"""Build ``(name, scope, scope_id)`` touch keys from memory rows.
The storage read helpers return ``SELECT *`` rows, so all three
columns are present.
"""
return [(r.get("name", ""), r.get("scope", ""), r.get("scope_id", "")) for r in rows]
def _touch_injected_memories(self, rows: list[dict[str, str]]) -> None:
"""Touch the memories injected into the system prefix this turn.
``_init_system_messages`` recomposes many times per turn; gate on the
per-turn touched-key set so each surfaced memory is counted at most
once between user turns. Best-effort: the facade swallows storage
errors, so a failed touch never breaks composition.
"""
fresh = [k for k in self._memory_keys(rows) if k not in self._touched_memory_keys]
if not fresh:
return
self._touched_memory_keys.update(fresh)
touch_structured_memories(fresh)
def _touch_read_memories(self, rows: list[dict[str, str]]) -> None:
"""Touch memories returned by an explicit memory-tool read.
A search/get is a distinct user-driven access each time it runs, so
these are counted unconditionally (not subject to the composition
per-turn dedup). Best-effort via the facade.
"""
touch_structured_memories(self._memory_keys(rows))
def _check_metacognitive_nudge(self, user_message: str) -> tuple[str, str] | None:
"""Check if a metacognitive nudge should fire for *user_message*.
@@ -9976,7 +10058,17 @@ class ChatSession:
# Surface client-side validation errors as tool errors rather
# than rendering them as a "successful" wait result.
if result.get("error"):
msg = f"Error: {result['error']}"
if result.get("not_found") or result.get("invalid_ws_ids"):
# Unresolvable-id failures carry a structured recovery
# payload — per-id ``did_you_mean``, the children
# roster, and (on the in-loop abort) live ``results``
# for the still-observable lanes. Serialize the whole
# object so the model can fix the id and re-issue; a
# bare-string collapse would discard exactly the hints
# the client built for it.
msg = "Error: " + json.dumps(result, separators=(",", ":"), default=str)
else:
msg = f"Error: {result['error']}"
self._report_tool_result(call_id, "wait_for_workstream", msg, is_error=True)
self._emit_wait_event(
"wait_ended",
@@ -9987,7 +10079,7 @@ class ChatSession:
elapsed = result.get("elapsed", 0.0)
complete = result.get("complete", False)
# Count children that genuinely finished work (real terminals
# only — ``denied`` is a rejection, not a resolution). Earlier
# only — ``not_found`` is a rejection, not a resolution). Earlier
# versions counted any non-empty ``state`` and inverted the
# truth on timeout (rendered as ``"timeout (N/N resolved)"``).
# Inline import — ``turnstone.core`` shouldn't import from
@@ -11443,6 +11535,7 @@ class ChatSession:
found_scope = scope
break
if mem:
self._touch_read_memories([mem])
content = mem.get("content", "")
desc = mem.get("description", "")
mem_type = mem.get("type", "")
@@ -11520,6 +11613,7 @@ class ChatSession:
result_count=len(rows),
query=item["query"][:120],
)
self._touch_read_memories(rows)
if rows:
lines = []
for m in rows:
+44 -19
View File
@@ -1387,6 +1387,34 @@ class SessionUIBase:
if decision:
self._persist_verdict_decisions([verdict], decision)
def on_superseded_intent_verdict(self, verdict: dict[str, Any]) -> None:
"""Persist (audit-only) a verdict whose judge generation was superseded.
``ChatSession._on_verdict`` routes here instead of
:meth:`on_intent_verdict` when a newer turn has replaced the
daemon's generation. The live surfaces deliberately stay
untouched no ``_llm_verdicts`` cache write, no SSE enqueue,
no ``_verdict_cond`` notify, no ``_pending_verdicts`` park
because the verdict's call_id belongs to an already-resolved
batch, and a model that reuses call_ids across turns could
ride a stale cached ``approve`` into a wrongful Smart Approval
of a *different* call. Dropping the verdict entirely (the
previous behavior) kept that safety property but left a
permanent hole in ``intent_verdicts``: the audit table said
"the judge never answered" for calls it actually ruled on.
``user_decision`` is stamped ``"superseded"`` no decision was
ever taken on THIS verdict; its call's gate resolved before the
judge finished. The stamp only lands on fresh ``tier="llm"``
rows: a superseded *fallback* reuses its heuristic row's
verdict_id, and ``upsert_intent_verdict`` excludes
``user_decision`` from the on-conflict SET, so the decision
already recorded on that row survives the tier upgrade.
"""
row = dict(verdict)
row.setdefault("user_decision", "superseded")
self._persist_intent_verdict(row)
def _record_judge_metric(self, verdict: dict[str, Any]) -> None:
"""Extension point for transport-specific Prometheus metrics.
@@ -1452,25 +1480,22 @@ class SessionUIBase:
}
for v in verdicts
]
# Plain INSERT (not UPSERT) at the bulk site. The race
# where a daemon-judge verdict lands BEFORE this bulk
# write IS reachable today: ``_evaluate_intent``
# (session.py) spawns the daemon thread before
# ``approve_tools`` is called, and the daemon's first
# emission (heuristic-only short batch, fast LLM response,
# or cancel-event ``_deliver_fallbacks`` from judge.py)
# can fire ``_persist_intent_verdict`` before this bulk
# INSERT runs. Outcome of that race is unchanged by the
# per-row UPSERT switch: the bulk INSERT statement aborts
# on PK collision regardless of whether the colliding row
# was planted by INSERT or UPSERT, and the wrapping
# ``try/except`` swallows it. Race A (daemon fires AFTER
# bulk) IS improved by the fix: heuristic→llm_fallback
# upgrade-in-place now lands. Future bulk-side hardening
# (``ON CONFLICT DO NOTHING``) would preserve the OTHER
# rows in the batch when one collides, but would keep the
# daemon's ``tier`` ("llm"/"llm_fallback") for the
# colliding row instead of the bulk's heuristic stamp.
# The daemon-judge race where a verdict lands BEFORE this
# bulk write IS reachable: ``_evaluate_intent`` (session.py)
# spawns the daemon thread before ``approve_tools`` is
# called, and the daemon's first emission (heuristic-only
# short batch, fast LLM response, or cancel-event
# ``_deliver_fallbacks`` from judge.py) can fire
# ``_persist_intent_verdict`` first — a fallback UPSERT
# plants the very ``verdict_id`` this batch is about to
# INSERT. The bulk site inserts ``ON CONFLICT DO NOTHING``
# so that one collision skips only its own row: the rest of
# the batch still lands, and the colliding row keeps the
# daemon's ``llm_fallback`` tier upgrade instead of being
# regressed to the heuristic stamp. (Plain INSERT here used
# to abort the entire statement — and the ``try/except``
# below swallowed it — discarding the whole batch's
# heuristic rows.)
storage.create_intent_verdicts_bulk(rows)
except Exception:
log.debug("Failed to bulk-persist intent verdicts", exc_info=True)
+25 -6
View File
@@ -114,17 +114,19 @@ from turnstone.core.storage._utils import (
from turnstone.core.storage._utils import (
escape_like as _escape_like,
)
from turnstone.core.storage._utils import (
find_orphan_conversations,
prepare_provider_data_for_save,
purge_orphan_conversations,
release_attachment_refs,
sanitize_text,
)
from turnstone.core.storage._utils import (
normalize_search_terms as _normalize_search_terms,
)
from turnstone.core.storage._utils import (
parse_attachment_refs as _parse_attachment_refs,
)
from turnstone.core.storage._utils import (
prepare_provider_data_for_save,
release_attachment_refs,
sanitize_text,
)
from turnstone.core.storage._utils import (
reconstruct_messages as _reconstruct_messages,
)
@@ -909,6 +911,16 @@ class PostgreSQLBackend:
conn.commit()
return result.rowcount > 0
def list_orphan_conversations(self) -> list[dict[str, Any]]:
with self._conn() as conn:
return find_orphan_conversations(conn)
def delete_orphan_conversations(self, ws_ids: list[str]) -> dict[str, int]:
with self._conn() as conn:
result = purge_orphan_conversations(conn, ws_ids)
conn.commit()
return result
# -- Workstream attachments (content-addressed, refcounted) ----------------
def save_attachment(
@@ -3642,6 +3654,10 @@ class PostgreSQLBackend:
conn.commit()
def create_intent_verdicts_bulk(self, verdicts: list[dict[str, Any]]) -> None:
# ON CONFLICT DO NOTHING — see the protocol docstring for the
# daemon-races-the-bulk-write rationale.
from sqlalchemy.dialects.postgresql import insert as pg_insert
if not verdicts:
return
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
@@ -3667,7 +3683,10 @@ class PostgreSQLBackend:
for v in verdicts
]
with self._conn() as conn:
conn.execute(sa.insert(intent_verdicts), rows)
conn.execute(
pg_insert(intent_verdicts).on_conflict_do_nothing(index_elements=["verdict_id"]),
rows,
)
conn.commit()
def get_intent_verdict(self, verdict_id: str) -> dict[str, Any] | None:
+41 -2
View File
@@ -633,6 +633,29 @@ class StorageBackend(Protocol):
"""Delete a workstream and all its conversations + config."""
...
def list_orphan_conversations(self) -> list[dict[str, Any]]:
"""Conversation ws_ids with no ``workstreams`` row.
One dict per orphan workstream keys ``ws_id``, ``rows``, ``first``,
``last`` (ISO text timestamps), ``attachment_refs`` ordered
oldest-first. Read-only; feeds the ``turnstone-admin
orphan-conversations`` maintenance verb.
"""
...
def delete_orphan_conversations(self, ws_ids: list[str]) -> dict[str, int]:
"""Purge conversation rows for the *ws_ids* that are STILL orphaned.
Orphan-ness is enforced inside the DELETE itself (correlated
``NOT EXISTS`` against ``workstreams``) and refcounts are released
from its ``RETURNING`` a ws_id registered before or during the
purge keeps both its rows and its refcounts. Sweeps the purged
ws_ids' ``workstream_config`` / ``workstream_overrides`` rows.
Returns counts keyed ``workstreams``, ``rows``, ``released_refs``,
``skipped`` (distinct inputs not purged).
"""
...
def list_workstreams(
self,
node_id: str | None = None,
@@ -1676,6 +1699,11 @@ class StorageBackend(Protocol):
``"approved"`` / ``"denied"`` / ``"timeout"`` (user-driven) or
``"policy"`` / ``"blanket"`` / ``"auto_approve_tools"``
(auto-approve reason, mirroring :class:`AutoApproveReason`).
Rows whose verdict landed only after a newer turn replaced the
judge generation are written directly with ``"superseded"``
no decision was ever taken on that verdict (its call's gate
resolved before the judge finished); see
``SessionUIBase.on_superseded_intent_verdict``.
"""
...
@@ -1731,8 +1759,10 @@ class StorageBackend(Protocol):
Used by :meth:`SessionUIBase._persist_intent_verdict` for every
async LLM-tier delivery; the synchronous heuristic-bulk path
(:meth:`create_intent_verdicts_bulk`) stays as plain INSERT
since each heuristic UUID is freshly generated per turn.
(:meth:`create_intent_verdicts_bulk`) inserts with per-row
``ON CONFLICT DO NOTHING`` instead its UUIDs are freshly
generated per turn, but the daemon can race a fallback UPSERT
of one of those same IDs in ahead of the bulk write.
"""
...
@@ -1749,6 +1779,15 @@ class StorageBackend(Protocol):
vocabulary. Used by the synchronous heuristic-verdict
persistence loop in ``approve_tools`` so a tool-heavy turn
doesn't pay N×commit latency before the approval prompt renders.
Inserts ``ON CONFLICT (verdict_id) DO NOTHING``: the async judge
daemon's first delivery can UPSERT a fallback row — which reuses
a heuristic ``verdict_id`` from this very batch before the
bulk write runs. Aborting the whole statement on that collision
(plain-INSERT behavior) silently discarded every other row in
the batch; skipping just the colliding row keeps the rest AND
preserves the daemon's ``llm_fallback`` tier upgrade rather
than regressing it to the heuristic stamp.
"""
...
+27 -6
View File
@@ -114,17 +114,19 @@ from turnstone.core.storage._utils import (
from turnstone.core.storage._utils import (
escape_like as _escape_like,
)
from turnstone.core.storage._utils import (
find_orphan_conversations,
prepare_provider_data_for_save,
purge_orphan_conversations,
release_attachment_refs,
sanitize_text,
)
from turnstone.core.storage._utils import (
normalize_search_terms as _normalize_search_terms,
)
from turnstone.core.storage._utils import (
parse_attachment_refs as _parse_attachment_refs,
)
from turnstone.core.storage._utils import (
prepare_provider_data_for_save,
release_attachment_refs,
sanitize_text,
)
from turnstone.core.storage._utils import (
reconstruct_messages as _reconstruct_messages,
)
@@ -1055,6 +1057,16 @@ class SQLiteBackend:
conn.commit()
return result.rowcount > 0
def list_orphan_conversations(self) -> list[dict[str, Any]]:
with self._conn() as conn:
return find_orphan_conversations(conn)
def delete_orphan_conversations(self, ws_ids: list[str]) -> dict[str, int]:
with self._conn() as conn:
result = purge_orphan_conversations(conn, ws_ids)
conn.commit()
return result
# -- Workstream attachments (content-addressed, refcounted) ----------------
def save_attachment(
@@ -3815,6 +3827,10 @@ class SQLiteBackend:
conn.commit()
def create_intent_verdicts_bulk(self, verdicts: list[dict[str, Any]]) -> None:
# ON CONFLICT DO NOTHING — see the protocol docstring for the
# daemon-races-the-bulk-write rationale.
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
if not verdicts:
return
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
@@ -3840,7 +3856,12 @@ class SQLiteBackend:
for v in verdicts
]
with self._conn() as conn:
conn.execute(sa.insert(intent_verdicts), rows)
conn.execute(
sqlite_insert(intent_verdicts).on_conflict_do_nothing(
index_elements=["verdict_id"]
),
rows,
)
conn.commit()
def get_intent_verdict(self, verdict_id: str) -> dict[str, Any] | None:
+120 -1
View File
@@ -12,7 +12,13 @@ import sqlalchemy as sa
from turnstone.core.attachments import unreadable_placeholder
from turnstone.core.log import get_logger
from turnstone.core.storage._schema import workstream_attachments
from turnstone.core.storage._schema import (
conversations,
workstream_attachments,
workstream_config,
workstream_overrides,
workstreams,
)
from turnstone.core.trajectory import (
AttachmentRef,
ContentBlock,
@@ -173,6 +179,119 @@ def release_attachment_refs(conn: Any, attachment_ids: list[str]) -> None:
)
def find_orphan_conversations(conn: Any) -> list[dict[str, Any]]:
"""Conversation ws_ids that have no ``workstreams`` row, with row stats.
Orphans come from writers that persisted without a registered workstream:
historically the pre-unification CLI/server paths, and the
delete-during-inflight race (a late tool-result save re-creating rows
after ``delete_workstream``). Read-only; ordered oldest-first. Each
entry carries the attachment-ref count so a purge's refcount release is
visible before it happens.
"""
anti_join = conversations.outerjoin(workstreams, conversations.c.ws_id == workstreams.c.ws_id)
rows = conn.execute(
sa.select(
conversations.c.ws_id,
sa.func.count().label("row_count"),
sa.func.min(conversations.c.timestamp).label("first"),
sa.func.max(conversations.c.timestamp).label("last"),
)
.select_from(anti_join)
.where(workstreams.c.ws_id.is_(None))
.group_by(conversations.c.ws_id)
.order_by(sa.func.min(conversations.c.timestamp))
).fetchall()
# Ref counts in ONE pass over the orphan rows that carry attachments —
# not a query per orphan workstream, so the scan stays proportional to
# orphan ROW count.
ref_counts: dict[str, int] = {}
ref_rows = conn.execute(
sa.select(conversations.c.ws_id, conversations.c.attachments)
.select_from(anti_join)
.where(
sa.and_(
workstreams.c.ws_id.is_(None),
conversations.c.attachments.is_not(None),
)
)
).fetchall()
for ws_id, refs in ref_rows:
ref_counts[ws_id] = ref_counts.get(ws_id, 0) + len(parse_attachment_refs(refs))
return [
{
"ws_id": ws_id,
"rows": int(row_count),
"first": first,
"last": last,
"attachment_refs": ref_counts.get(ws_id, 0),
}
for ws_id, row_count, first, last in rows
]
# IN-list chunk size for the purge statements — mirrors the storage layer's
# existing bulk chunking (SQLite bind-parameter limits).
_PURGE_CHUNK = 500
def purge_orphan_conversations(conn: Any, ws_ids: list[str]) -> dict[str, int]:
"""Delete conversation rows for the *ws_ids* that are STILL orphans.
Orphan-ness is enforced INSIDE the DELETE itself (a correlated
``NOT EXISTS`` against ``workstreams``), and the refcounts to release
come from the DELETE's ``RETURNING`` — so refs are released for exactly
the rows that were deleted. A ws_id registered at any point before the
DELETE statement keeps both its rows AND its refcounts; there is no
pre-count/delete window to underflow. (Needs ``DELETE .. RETURNING``:
PostgreSQL, or SQLite 3.35.)
Input is de-duplicated and all IN-lists are chunked. ``skipped`` =
distinct input ws_ids not purged (registered before/during the purge, or
no rows). The purged ws_ids' ``workstream_config`` /
``workstream_overrides`` rows are swept. Caller owns commit.
"""
distinct = list(dict.fromkeys(ws_ids))
if not distinct:
return {"workstreams": 0, "rows": 0, "released_refs": 0, "skipped": 0}
ref_ids: list[str] = []
purged_ws: set[str] = set()
rows_deleted = 0
for i in range(0, len(distinct), _PURGE_CHUNK):
chunk = distinct[i : i + _PURGE_CHUNK]
returned = conn.execute(
sa.delete(conversations)
.where(
sa.and_(
conversations.c.ws_id.in_(chunk),
~sa.exists(
sa.select(workstreams.c.ws_id).where(
workstreams.c.ws_id == conversations.c.ws_id
)
),
)
)
.returning(conversations.c.ws_id, conversations.c.attachments)
).fetchall()
for ws_id, refs in returned:
purged_ws.add(ws_id)
rows_deleted += 1
if refs:
ref_ids.extend(parse_attachment_refs(refs))
release_attachment_refs(conn, ref_ids)
swept = sorted(purged_ws)
for i in range(0, len(swept), _PURGE_CHUNK):
chunk = swept[i : i + _PURGE_CHUNK]
conn.execute(sa.delete(workstream_config).where(workstream_config.c.ws_id.in_(chunk)))
conn.execute(sa.delete(workstream_overrides).where(workstream_overrides.c.ws_id.in_(chunk)))
return {
"workstreams": len(purged_ws),
"rows": rows_deleted,
"released_refs": len(ref_ids),
"skipped": len(distinct) - len(purged_ws),
}
def attachment_to_content_part(att: dict[str, Any]) -> dict[str, Any] | None:
"""Convert a stored attachment row into an OpenAI-style content part.
+118 -7
View File
@@ -13,6 +13,7 @@ Flow:
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
@@ -28,6 +29,81 @@ log = get_logger(__name__)
_RENEW_INTERVAL_HOURS = 24
_RENEW_BEFORE_EXPIRY_DAYS = 1
# Boot-time init retry budget: 1+2+4+8+16 s ≈ 31 s of backoff. Sized to
# absorb a whole-stack restart, where every node races the console for the
# CA cert (compose re-enforces depends_on ordering only on `up`, not
# `restart`) and the console needs a few seconds to start accepting
# connections.
TLS_INIT_RETRY_ATTEMPTS = 6
def tls_pem_runtime_dir() -> Path:
"""Parent directory for the boot-time PEM files.
A fixed, well-known location (override: ``TURNSTONE_TLS_PEM_DIR``) so the
container healthcheck can present the node's own cert as an mTLS client
cert without DB access. ``write_pem_files`` creates a ``lacme-pem-*``
subdirectory under it.
"""
import os
import tempfile
env = os.environ.get("TURNSTONE_TLS_PEM_DIR")
return Path(env) if env else Path(tempfile.gettempdir()) / "turnstone-tls"
def prepare_pem_runtime_dir() -> Path:
"""Create the PEM runtime dir (0700) and clear stale ``lacme-pem-*`` dirs.
Stale subdirectories accumulate when a previous process dies before its
atexit cleanup runs (SIGKILL, OOM). Clearing them at boot before the new
PEM dir is written keeps exactly one live dir, so the healthcheck can't
pick up an expired cert. Assumes one node per PEM root: two processes
sharing a root would clear each other's live dirs (containers each get a
private tmpfs; on bare metal set TURNSTONE_TLS_PEM_DIR per node).
"""
import os
import shutil
import stat
root = tls_pem_runtime_dir()
try:
st = os.lstat(root)
except FileNotFoundError:
st = None
if st is not None and (stat.S_ISLNK(st.st_mode) or st.st_uid != os.geteuid()):
# The default root lives in shared /tmp on bare metal: a hostile
# local user could pre-create it as a symlink (redirecting where the
# key material lands) or as a dir they own. Refuse both; our own
# stale dir from a prior boot passes (chmod below repairs mode).
raise RuntimeError(
f"PEM runtime dir {root} exists but is a symlink or not owned by this process"
)
root.mkdir(mode=0o700, parents=True, exist_ok=True)
os.chmod(root, 0o700)
for stale in root.glob("lacme-pem-*"):
shutil.rmtree(stale, ignore_errors=True)
return root
def refresh_runtime_pems(bundle: Any, *, ca_pem: bytes | None, previous: Path | None) -> Any:
"""Write a renewed bundle under the runtime root and drop the old dir.
Keeps the on-disk PEMs (the healthcheck's mTLS client identity) in
lockstep with the served cert: certs live 48 hours, so the boot-time
files would expire and flip the container unhealthy two renewals in.
The new dir is written before the old one is removed, so a concurrent
probe always finds at least one complete dir.
"""
import shutil
from lacme.mtls import write_pem_files
new_paths = write_pem_files(bundle, ca_pem=ca_pem, directory=tls_pem_runtime_dir())
if previous is not None and previous != new_paths.cert.parent:
shutil.rmtree(previous, ignore_errors=True)
return new_paths
def _require_lacme() -> Any:
try:
@@ -203,17 +279,49 @@ class TLSClient:
except Exception:
log.warning("tls.cert.reload_hook_failed", exc_info=True)
async def init(self) -> None:
async def init(self, *, attempts: int = 1, base_delay: float = 1.0) -> None:
"""Fetch CA root cert and request a service certificate.
If no console_url was provided, discovers it from the services
table. Performs initial cert provisioning over plain HTTP (ACME
protocol provides integrity via JWS).
With ``attempts > 1``, failures are retried with exponential backoff
(``base_delay * 2**n``). A node restarted alongside the console loses
the race for the console's listener by well under a second; without
retries that one refused connection downgrades the node to plain HTTP
for its entire lifetime, even when a valid cert sits in the store.
Discovery, CA fetch, and cert request are all idempotent, so the whole
sequence is retried as a unit.
"""
if not self._console_url:
self._console_url = self._discover_console_url()
await self._fetch_ca_cert()
await self._request_cert()
import asyncio
if attempts < 1:
# range(1, attempts + 1) would be empty: init() would return
# "successfully" with no CA and no cert.
raise ValueError(f"attempts must be >= 1, got {attempts}")
if base_delay < 0:
raise ValueError(f"base_delay must be >= 0, got {base_delay}")
for attempt in range(1, attempts + 1):
try:
if not self._console_url:
self._console_url = self._discover_console_url()
await self._fetch_ca_cert()
await self._request_cert()
return
except Exception as exc:
if attempt >= attempts:
raise
delay = base_delay * 2 ** (attempt - 1)
log.warning(
"tls.init.retrying",
attempt=attempt,
max_attempts=attempts,
delay_seconds=delay,
error=f"{type(exc).__name__}: {exc}",
)
await asyncio.sleep(delay)
def _discover_console_url(self) -> str:
"""Look up the console URL from the services table."""
@@ -245,8 +353,11 @@ class TLSClient:
resp.raise_for_status()
self._ca_pem = resp.content
log.info("tls.ca.fetched", url=url)
except Exception:
log.error("tls.ca.fetch_failed", url=url, exc_info=True)
except Exception as exc:
# Warning, not error: init() may retry this, and the terminal
# failure is logged by the caller. Full traceback at debug.
log.warning("tls.ca.fetch_failed", url=url, error=f"{type(exc).__name__}: {exc}")
log.debug("tls.ca.fetch_failed traceback", exc_info=True)
raise
async def _request_cert(self) -> None:
+37 -5
View File
@@ -1395,6 +1395,12 @@ def _build_health_dict(app_state: Any) -> dict[str, Any]:
"resources": mc.resource_count,
"prompts": mc.prompt_count,
}
# Only present when tls.enabled: "active" (serving HTTPS) or "fallback"
# (TLS init failed, serving plain HTTP). Makes a silently-downgraded
# node observable.
tls_state = getattr(app_state, "tls_state", None)
if tls_state:
data["tls"] = tls_state
return data
@@ -4085,7 +4091,7 @@ def main() -> None:
parser.add_argument(
"--skip-permissions",
action="store_true",
help="Auto-approve all tool calls (no confirmation prompts)",
help="Auto-approve all tool calls without prompting (same as tools.skip_permissions)",
)
# MCP config path is bootstrap-critical (needed before ConfigStore for tool loading)
parser.add_argument(
@@ -4635,7 +4641,12 @@ def main() -> None:
try:
import asyncio
from turnstone.core.tls import TLSClient, build_cert_hostnames
from turnstone.core.tls import (
TLS_INIT_RETRY_ATTEMPTS,
TLSClient,
build_cert_hostnames,
prepare_pem_runtime_dir,
)
# The advertised host (the name the collector + routing proxy dial)
# is placed first so it becomes the cert's primary domain / SAN and
@@ -4651,14 +4662,17 @@ def main() -> None:
storage=get_storage(),
hostnames=hostnames,
)
asyncio.run(tls_client.init())
asyncio.run(tls_client.init(attempts=TLS_INIT_RETRY_ATTEMPTS))
bundle = tls_client.bundle
if bundle:
from lacme.mtls import write_pem_files_persistent
# Fixed parent dir (vs. a random tmpdir) so the container
# healthcheck can find the cert and probe over mTLS.
pem_paths = write_pem_files_persistent(
bundle,
ca_pem=tls_client.ca_pem,
directory=prepare_pem_runtime_dir(),
)
ssl_kwargs.update(pem_paths.as_uvicorn_kwargs())
if tls_client.ca_pem:
@@ -4668,15 +4682,28 @@ def main() -> None:
# Store client on app state for lifespan renewal
app.state.tls_client = tls_client
pem_dir_state = {"dir": pem_paths.cert.parent}
def _reload_server_cert(new_bundle: Any) -> None:
"""Swap a renewed cert into uvicorn's live SSL context.
uvicorn loads its cert once at boot and never reloads, so
without this the served cert would expire mid-process and
break every mTLS peer.
break every mTLS peer. The on-disk runtime PEMs (the
healthcheck's client identity) expire on the same clock,
so they are refreshed alongside.
"""
from turnstone.core.tls import swap_context_cert
from turnstone.core.tls import refresh_runtime_pems, swap_context_cert
try:
new_paths = refresh_runtime_pems(
new_bundle,
ca_pem=tls_client.ca_pem,
previous=pem_dir_state["dir"],
)
pem_dir_state["dir"] = new_paths.cert.parent
except Exception:
log.warning("TLS runtime PEM refresh failed", exc_info=True)
cfg = getattr(app.state, "uvicorn_config", None)
live_ctx = getattr(cfg, "ssl", None) if cfg is not None else None
@@ -4691,10 +4718,15 @@ def main() -> None:
app.state.advertise_url = _advertise_url.replace("http://", "https://", 1)
else:
app.state.advertise_url = _advertise_url
app.state.tls_state = "active"
log.info("TLS enabled — serving HTTPS")
else:
app.state.tls_state = "fallback"
log.warning("TLS enabled but no cert available")
except Exception as exc:
# Surfaced as tls:"fallback" in /health — a node serving plain
# HTTP while TLS is configured should be visible, not silent.
app.state.tls_state = "fallback"
log.warning(
"TLS initialization failed — serving plain HTTP: %s: %s",
type(exc).__name__,
+8 -83
View File
@@ -163,100 +163,25 @@
background: var(--bg-highlight);
}
/* Delete modal id-scoped so the surface that owns it controls visibility.
Both ui/static (#ws-delete-overlay) and console/static
(#coord-delete-overlay) share the same shape via the .ws-delete-modal
class hooks below. */
.ws-delete-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
}
.ws-delete-modal-box {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 24px;
max-width: 480px;
width: 90%;
}
.ws-delete-modal-box h3 {
margin: 0 0 12px;
font-size: 16px;
color: var(--fg-bright);
}
.ws-delete-modal-list {
max-height: 200px;
overflow-y: auto;
margin: 12px 0;
}
.ws-delete-modal-list .ws-delete-item {
/* Batch-delete confirm list rendered by cards.js inside the
#ws-delete-dialog / #coord-delete-dialog hatch dialogs. The dialog
chrome, alert strip and scroll region are hatch.css's (the sh-body is
the only scroll region); only the row treatment lives here. */
.ws-delete-item {
padding: 6px 0;
font-size: 13px;
color: var(--fg-bright);
border-bottom: 1px solid var(--border);
/* Long aliases / raw ws_ids in the confirm + results list shouldn't
punch out of the modal at narrow viewports. */
punch out of the dialog at narrow viewports. */
word-break: break-word;
}
/* Modal alert region only painted when the controller writes a
message. Both close paths clear it, so :not(:empty) keeps the box
invisible at rest and avoids an empty-frame artefact. */
.ws-delete-modal-box [role="alert"]:not(:empty) {
background: rgba(248, 113, 113, 0.08);
border: 1px solid var(--red);
color: var(--red);
border-radius: var(--radius);
padding: 8px 10px;
font-size: 12px;
margin-bottom: 8px;
}
[data-theme="light"] .ws-delete-modal-box [role="alert"]:not(:empty) {
background: rgba(220, 38, 38, 0.06);
}
.ws-delete-modal-list .ws-delete-item:last-child {
.ws-delete-item:last-child {
border-bottom: none;
}
.ws-delete-modal-list .ws-delete-item.ws-delete-error {
.ws-delete-item.ws-delete-error {
color: var(--red);
}
.ws-delete-modal-buttons {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 16px;
}
.ws-delete-modal-buttons button {
padding: 8px 20px;
border-radius: var(--radius);
font-size: 13px;
font-weight: 500;
cursor: pointer;
border: 1px solid var(--border);
background: transparent;
color: var(--fg-bright);
}
.ws-delete-modal-buttons button.ws-delete-confirm {
/* Mirror .ws-delete-bar-btn's contrast bump same destructive
filled-button treatment, same dark-theme AA fix. */
background: #dc2626;
color: #fff;
border-color: #dc2626;
}
[data-theme="light"] .ws-delete-modal-buttons button.ws-delete-confirm {
background: var(--red);
border-color: var(--red);
}
/* `.ws-delete-close` is a state-marker, not a colour rule the
controller drops `.ws-delete-confirm` when the modal transitions to
the post-delete "Close" state, and the default
`.ws-delete-modal-buttons button` rule above already provides the
transparent / fg-bright / border styling. The class itself is useful
for DOM inspection and as a future hook. */
/* ==========================================================================
Pagination shared by the console's filtered admin lists and the saved
+95 -105
View File
@@ -577,21 +577,22 @@ export function createSavedTable(opts) {
- delete-mode state (active flag + selected ws_id set)
- card decoration (checkbox + key/click overrides)
- the bottom toolbar wiring (count, Select All, Delete Selected)
- the confirmation modal (focus trap, batch fan-out, results view)
- the confirmation dialog (hatch dialog tier: batch fan-out + results
view; focus trap / Escape / busy lock belong to hatch.js)
It does NOT own how cards get fetched or rendered the caller's
render() is invoked when the controller needs the list redrawn (mode
transitions, Select-All toggles).
Required opts:
idPrefix DOM-id prefix shared by the toolbar + modal
idPrefix DOM-id prefix shared by the toolbar + dialog
(e.g. "ws-delete" / "coord-delete"). The DOM
must already contain `${idPrefix}-bar`,
`${idPrefix}-bar-count`, `${idPrefix}-bar-delete`,
`${idPrefix}-bar-select-all`, `${idPrefix}-overlay`,
`${idPrefix}-box`, `${idPrefix}-error`,
`${idPrefix}-bar-select-all`, `${idPrefix}-dialog`
(a `dialog.hatch.hatch--dialog`), `${idPrefix}-error`,
`${idPrefix}-count`, `${idPrefix}-list`,
`${idPrefix}-confirm-btn`, `${idPrefix}-cancel-btn`.
`${idPrefix}-meta`, `${idPrefix}-confirm-btn`.
buttonId id of the section's start/cancel toggle button.
noun singular display word for the item kind, e.g.
"workstream" / "coordinator". Used in toast +
@@ -610,11 +611,6 @@ export function createSavedTable(opts) {
*/
export function createSavedCardsController(opts) {
var state = { mode: false, selected: {}, items: [] };
var batchTrap = null;
/* Element that owned focus when the modal opened restored in
closeModal() so keyboard users land back on the toggle button (or
wherever they came from) instead of <body>. WCAG 2.4.3. */
var prevFocus = null;
function $(id) {
return document.getElementById(opts.idPrefix + "-" + id);
@@ -767,7 +763,7 @@ export function createSavedCardsController(opts) {
}
function _byId() {
/* Single-pass index over the visible items so the modal + fan-out
/* Single-pass index over the visible items so the dialog + fan-out
paths don't repeat O(N) `find` calls per selection. */
var map = {};
state.items.forEach(function (s) {
@@ -776,6 +772,12 @@ export function createSavedCardsController(opts) {
return map;
}
function _deleteLabel(count) {
return (
"Delete " + count + " " + (count === 1 ? opts.noun : opts.noun + "s")
);
}
function confirmSelection() {
var selected = Object.keys(state.selected);
if (!selected.length) {
@@ -785,14 +787,26 @@ export function createSavedCardsController(opts) {
return;
}
var byId = _byId();
var overlay = $("overlay");
var dlg = $("dialog");
var countEl = $("count");
var listEl = $("list");
var errorEl = $("error");
if (errorEl) errorEl.textContent = "";
var metaEl = $("meta");
if (errorEl) {
errorEl.textContent = "";
errorEl.classList.remove("is-visible");
}
/* The results view hides Cancel (Close-only foot) and may have
flipped the chrome to the success kind restore both. */
var cancelBtn = dlg.querySelector(".sh-foot [data-close]");
if (cancelBtn) cancelBtn.hidden = false;
dlg.setAttribute("data-kind", "danger");
if (metaEl) metaEl.textContent = selected.length + " selected";
if (countEl) {
countEl.textContent =
selected.length + " " + opts.noun + "(s) will be permanently deleted:";
(selected.length === 1
? "This " + opts.noun
: "These " + opts.noun + "s") + " will be permanently deleted:";
}
if (listEl) {
listEl.replaceChildren();
@@ -807,92 +821,50 @@ export function createSavedCardsController(opts) {
}
var delBtn = $("confirm-btn");
if (delBtn) {
delBtn.textContent = "Delete";
delBtn.disabled = false;
delBtn.classList.remove("ws-delete-close");
delBtn.classList.add("ws-delete-confirm");
delBtn.textContent = _deleteLabel(selected.length);
delBtn.classList.add("sh-btn--danger");
delBtn.onclick = confirm;
}
var cancelBtn = $("cancel-btn");
if (cancelBtn) cancelBtn.disabled = false;
if (overlay) overlay.style.display = "flex";
if (batchTrap) document.removeEventListener("keydown", batchTrap);
batchTrap = function (e) {
if (e.key === "Escape") {
e.preventDefault();
closeModal();
return;
}
if (e.key === "Tab") {
var box = $("box");
if (!box) return;
var focusable = box.querySelectorAll("button:not(:disabled)");
if (!focusable.length) return;
var first = focusable[0];
var last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
};
document.addEventListener("keydown", batchTrap);
/* Snapshot the pre-modal focus owner so closeModal() can return to
it. Captured before we move focus into the dialog so the
restore-target is the caller, not the dialog itself. */
prevFocus = document.activeElement;
if (cancelBtn) cancelBtn.focus();
/* Hatch owns the rest: focus trap, Escape, backdrop click, the busy
lock, and focus restore to the opener. Cancel carries the markup
autofocus (destructive-confirm rule). The onClose runs on EVERY
dismissal path (footer Close, header , Escape, backdrop) once
results are showing, any of them must exit delete mode and refresh
the now-stale list, not just the footer button. */
state.resultsShown = false;
window.TurnstoneHatch.openDialog(dlg, {
onClose: function () {
if (!state.resultsShown) return; // pre-delete cancel keeps the mode
state.resultsShown = false;
cancel();
var t = document.getElementById(opts.buttonId);
if (t && typeof t.focus === "function") t.focus();
if (typeof opts.onClose === "function") opts.onClose();
},
});
}
function closeModal() {
var overlay = $("overlay");
if (overlay) overlay.style.display = "none";
if (batchTrap) {
document.removeEventListener("keydown", batchTrap);
batchTrap = null;
}
/* Pick the most useful focus target:
1. prevFocus (where the user came from), if it's still in the
DOM and visible. Esc / Cancel paths land here the bar is
still on screen, so focus returns to "Delete Selected".
2. The section toggle button always present, semantic exit
point for the flow. Used when prevFocus has been hidden by
cancel() (post-delete Close path: cancel() ran first and
put `.ws-delete-bar` at display:none, so the bar's button
is no longer focusable). */
var target = prevFocus;
if (!target || target.offsetParent === null) {
target = document.getElementById(opts.buttonId);
}
if (target && typeof target.focus === "function") {
try {
target.focus();
} catch (_) {
/* node detached between open and close — give up silently */
}
}
prevFocus = null;
var dlg = $("dialog");
if (dlg && dlg.open) dlg.close();
}
function confirm() {
var selected = Object.keys(state.selected);
if (!selected.length) return;
var byId = _byId();
var dlg = $("dialog");
var errorEl = $("error");
var listEl = $("list");
var countEl = $("count");
var metaEl = $("meta");
var delBtn = $("confirm-btn");
var cancelBtn = $("cancel-btn");
if (errorEl) errorEl.textContent = "";
if (delBtn) {
delBtn.disabled = true;
delBtn.textContent = "Deleting...";
if (errorEl) {
errorEl.textContent = "";
errorEl.classList.remove("is-visible");
}
if (cancelBtn) cancelBtn.disabled = true;
/* LED pulses, actions lock, dismissal refused while the fan-out runs. */
window.TurnstoneHatch.setBusy(dlg, true);
var results = [];
var promises = selected.map(function (wsId) {
@@ -918,7 +890,15 @@ export function createSavedCardsController(opts) {
/* fall through */
}
} else if (body) {
errMsg = shortId + ": " + body.substring(0, 200);
// Non-JSON failures are often whole HTML error pages (proxy
// 502s, gateway timeouts) — strip markup before display.
var plain = body
.replace(/<(style|script)[\s\S]*?<\/\1>/gi, " ")
.replace(/<[^>]+>/g, " ")
.replace(/\s+/g, " ")
.trim();
errMsg =
shortId + ": " + (plain.substring(0, 120) || "HTTP " + status);
}
results.push({
name: name,
@@ -939,6 +919,7 @@ export function createSavedCardsController(opts) {
});
Promise.all(promises).then(function () {
window.TurnstoneHatch.setBusy(dlg, false);
if (listEl) {
listEl.replaceChildren();
results.forEach(function (r) {
@@ -958,27 +939,36 @@ export function createSavedCardsController(opts) {
if (countEl) {
countEl.textContent = okCount + " deleted, " + failCount + " failed";
}
if (delBtn) {
delBtn.disabled = false;
delBtn.textContent = "Close";
/* Swap modifier classes so styling is intent-driven instead of
cascade-positional: the Close button picks up the default
".ws-delete-modal-buttons button" rule once .ws-delete-confirm
is removed. */
delBtn.classList.remove("ws-delete-confirm");
delBtn.classList.add("ws-delete-close");
delBtn.onclick = function () {
/* Order matters: cancel() reshapes the toggle button via
setIconButton(), which preserves the element identity but
swaps its subtree. closeModal() then focuses prevFocus
which IS that toggle button landing on a freshly rebuilt
"Delete" affordance instead of <body>. */
cancel();
closeModal();
if (typeof opts.onClose === "function") opts.onClose();
};
// Failures land in the live alert region (the summary prose is
// polite-live for the all-good case); a clean run flips the chrome
// to the success kind — red head over "3 deleted, 0 failed" would
// disagree with the de-dangered foot.
if (errorEl && failCount > 0) {
errorEl.textContent =
failCount + " of " + results.length + " deletions failed";
errorEl.classList.add("is-visible");
}
if (dlg)
dlg.setAttribute("data-kind", failCount === 0 ? "success" : "danger");
if (metaEl) metaEl.textContent = "";
/* Close-only foot: a Cancel beside a Close would be the redundant
dismissal pair the foot grammar forbids. */
var cancelBtn = dlg ? dlg.querySelector(".sh-foot [data-close]") : null;
if (cancelBtn) cancelBtn.hidden = true;
state.resultsShown = true;
if (delBtn) {
delBtn.textContent = "Close";
/* The results view's action is no longer destructive drop the
danger fill (confirmSelection restores it on the next open). */
delBtn.classList.remove("sh-btn--danger");
/* Teardown (exit delete mode, refresh the stale list, focus the
rebuilt section toggle) lives on the dialog's onClose so the
header / Escape / backdrop run it too Close just closes. */
delBtn.onclick = closeModal;
// The state just changed under the user — land focus somewhere
// predictable (the only remaining action).
delBtn.focus();
}
if (cancelBtn) cancelBtn.disabled = false;
});
}
File diff suppressed because it is too large Load Diff
+238
View File
@@ -0,0 +1,238 @@
/* Service-hatch containers behaviour for the two mounting points styled by
* hatch.css:
*
* - openShelf(dlg, opts): the pane-scoped, NON-modal create/edit/inspect
* shelf (`dialog.show()`). The dialog must live inside its pane's
* `.hatch-host` element: the host is the containing block, gets a lazy
* `.pane-scrim` sibling, and its OTHER children are made `inert` while
* the shelf is open the rail, tab bar and any other pane stay live
* (split panes work by construction; the shelf persists with its pane).
* Escape is controller-owned (non-modal dialogs have no native cancel)
* and defers to any document-modal dialog stacked above.
*
* - openDialog(dlg, opts): the document-modal confirm / show-once tier
* (`showModal()`). Native top layer, focus trap, Escape; backdrop click
* closes (geometry test the dialog is the click target only outside
* its box).
*
* Both: `[data-close]` descendants close the container; `setBusy(dlg, on)`
* toggles the `data-busy` lock (LED pulses, actions lock, dismissal is
* refused). Focus returns to `opts.opener` (default: the element focused
* at open time). `opts.onClose` fires exactly once per open.
*
* Classic (non-module) scripts reach these via the `window.TurnstoneHatch`
* bridge at the bottom only ever from event handlers, never at parse
* time (module execution is deferred; the bridge does not exist yet while
* a classic script's top level runs).
*/
"use strict";
/** dialog -> open-state for shelves; presence means "open". */
const _shelfState = new Map();
let _escInstalled = false;
function _hostOf(dlg) {
const host = dlg.closest(".hatch-host") || dlg.parentElement;
if (!host) throw new Error("hatch: shelf dialog has no host element");
return host;
}
function _scrimFor(host) {
let scrim = null;
for (const el of host.children) {
if (el.classList && el.classList.contains("pane-scrim")) {
scrim = el;
break;
}
}
if (!scrim) {
scrim = document.createElement("div");
scrim.className = "pane-scrim";
scrim.hidden = true;
scrim.addEventListener("click", () => {
for (const [dlg] of _shelfState) {
if (dlg.hasAttribute("data-busy")) continue; // submit in flight
if (_hostOf(dlg) === host) closeShelf(dlg);
}
});
host.appendChild(scrim);
}
return scrim;
}
function _pruneDetached() {
// A pane can be closed (PaneManager removes its element) while its shelf
// is open — the entry would otherwise pin a detached dialog forever and
// leave the document Escape listener targeting a ghost.
for (const [dlg] of _shelfState) {
if (!dlg.isConnected) closeShelf(dlg);
}
}
function _onEscape(e) {
if (e.key !== "Escape") return;
// A document-modal dialog above (confirm-from-shelf) owns Escape natively.
if (document.querySelector("dialog:modal")) return;
_pruneDetached();
let top = null;
for (const [dlg] of _shelfState) top = dlg; // Map preserves insertion order
if (!top) return;
if (top.hasAttribute("data-busy")) return; // submit in flight — hold the door
e.preventDefault();
closeShelf(top);
}
function _wireCloseDelegation(dlg, isShelf) {
if (dlg._hatchWired) return;
dlg._hatchWired = true;
// Busy is a HARD lock. pointer-events:none only stops the mouse — Enter on
// the still-focused primary dispatches a synthetic click that would reach
// the surface's submit handler and double-fire the request. Swallow every
// non-[data-close] activation at capture before surface listeners see it
// (and [data-close] is refused below anyway).
dlg.addEventListener(
"click",
(e) => {
if (dlg.hasAttribute("data-busy")) {
e.stopPropagation();
e.preventDefault();
}
},
{ capture: true },
);
// The dialog tier's native Escape arrives as `cancel` — hold that door too.
if (!isShelf) {
dlg.addEventListener("cancel", (e) => {
if (dlg.hasAttribute("data-busy")) e.preventDefault();
});
}
dlg.addEventListener("click", (e) => {
if (dlg.hasAttribute("data-busy")) return;
if (e.target.closest("[data-close]")) {
isShelf ? closeShelf(dlg) : dlg.close();
return;
}
if (!isShelf && e.target === dlg) {
// Modal tier backdrop click: outside the box, the dialog is the target.
const r = dlg.getBoundingClientRect();
const inside =
e.clientX >= r.left &&
e.clientX <= r.right &&
e.clientY >= r.top &&
e.clientY <= r.bottom;
if (!inside) dlg.close();
}
});
}
/**
* Open a pane-scoped shelf. `opts`:
* - opener: focus target on close (default: document.activeElement now).
* - onClose: notification, fires exactly once.
* Returns `{ close }`; `close()` is idempotent.
*/
export function openShelf(dlg, opts) {
opts = opts || {};
_pruneDetached();
if (_shelfState.has(dlg)) return { close: () => closeShelf(dlg) };
const host = _hostOf(dlg);
// One shelf per pane: a second open() retargets the pane's shelf slot.
for (const [other] of _shelfState) {
if (other !== dlg && _hostOf(other) === host) closeShelf(other);
}
const scrim = _scrimFor(host);
const inerted = [];
for (const el of host.children) {
if (el === dlg || el === scrim) continue;
if (el.tagName === "DIALOG" && el.classList.contains("hatch")) continue;
if (el.inert) continue; // already inert by someone else — leave it be
el.inert = true;
inerted.push(el);
}
_shelfState.set(dlg, {
opener: opts.opener || document.activeElement,
onClose: opts.onClose || null,
inerted,
scrim,
});
scrim.hidden = false;
_wireCloseDelegation(dlg, true);
dlg.show();
const auto = dlg.querySelector("[autofocus]");
if (auto) auto.focus();
if (!_escInstalled) {
document.addEventListener("keydown", _onEscape);
_escInstalled = true;
}
return { close: () => closeShelf(dlg) };
}
/** Close a shelf opened by openShelf. Idempotent. */
export function closeShelf(dlg) {
const state = _shelfState.get(dlg);
if (!state) return;
_shelfState.delete(dlg);
if (dlg.isConnected && dlg.open) dlg.close();
dlg.removeAttribute("data-busy");
for (const el of state.inerted) el.inert = false;
// Another shelf may still own this pane's scrim (retarget race) — only
// hide it when no open shelf shares the host.
let hostStillBusy = false;
for (const [other] of _shelfState) {
if (!other.isConnected) continue; // detached with its pane — not an owner
if (state.scrim.parentElement === _hostOf(other)) hostStillBusy = true;
}
if (!hostStillBusy) state.scrim.hidden = true;
if (_shelfState.size === 0 && _escInstalled) {
document.removeEventListener("keydown", _onEscape);
_escInstalled = false;
}
if (state.opener && state.opener.isConnected) state.opener.focus();
if (state.onClose) {
const cb = state.onClose;
state.onClose = null;
cb();
}
}
/**
* Open a document-modal dialog (confirm / show-once tier). Same opts as
* openShelf. Close via `[data-close]`, Escape (native), backdrop click,
* or the returned `close()`.
*/
export function openDialog(dlg, opts) {
opts = opts || {};
if (dlg.open) return { close: () => dlg.close() };
const opener = opts.opener || document.activeElement;
const onClose = opts.onClose || null;
_wireCloseDelegation(dlg, false);
dlg.addEventListener(
"close",
() => {
dlg.removeAttribute("data-busy");
if (opener && opener.isConnected) opener.focus();
if (onClose) onClose();
},
{ once: true },
);
dlg.showModal();
const auto = dlg.querySelector("[autofocus]");
if (auto) auto.focus();
return { close: () => dlg.close() };
}
/** Busy lock: LED pulses, actions lock, Escape/scrim/[data-close] refused. */
export function setBusy(dlg, busy) {
if (busy) {
dlg.setAttribute("data-busy", "");
dlg.setAttribute("aria-busy", "true");
} else {
dlg.removeAttribute("data-busy");
dlg.removeAttribute("aria-busy");
}
}
/* Transitional bridge for the classic admin/governance scripts (the
* toast.js `window.showToast` pattern). Handler-time use only. */
window.TurnstoneHatch = { openShelf, closeShelf, openDialog, setBusy };
+171 -4
View File
@@ -630,6 +630,28 @@ class Pane {
}
});
// Click-to-play for media embeds. Pane-owned (on this.el) and root-scoped
// via closest(".media-play-btn") so every embedded L-shell pane activates
// its own players — the old standalone wired this via a document-level
// delegated listener in app.js, which the console host never loaded (so the
// Play button was dead in console-hosted panes). Enter on a focused button
// routes through the same path, mirroring the approval keydown above.
this.el.addEventListener("click", (e) => {
const btn = e.target.closest(".media-play-btn");
if (!btn) return;
e.preventDefault();
activateMediaPlayButton(btn);
});
this.el.addEventListener("keydown", (e) => {
if (e.key !== "Enter") return;
const btn = e.target.closest(".media-play-btn");
if (!btn || btn.disabled) return;
// Single-path activation: preventDefault stops the browser's native
// Enter-to-click from dispatching a second activation behind ours.
e.preventDefault();
activateMediaPlayButton(btn);
});
// No pane header: the workstream name, persona, and state are shown by the
// tab and the rail (Workspaces); the --skip-permissions banner lands in
// messagesEl (see the host warningTarget). The standalone split-pane
@@ -2693,6 +2715,143 @@ function _tryPrettyJson(text) {
return _redactApiKeys(JSON.stringify(obj, null, 2));
}
// ---------------------------------------------------------------------------
// HLS lazy-loader + click-to-play (lifted from the standalone app.js so
// console-hosted panes activate media too). Follows the mermaid.js
// lazy-load pattern in /shared/renderer.js: the vendor is fetched by absolute
// /shared/ URL on first use, so it resolves in BOTH the standalone server and
// the console (where /shared is mounted at the root and node-proxied panes
// also reach it via /node/{id}/shared/).
// ---------------------------------------------------------------------------
let _hlsState = "idle";
let _hlsQueue = [];
function _loadHls(callback) {
if (_hlsState === "ready") {
callback();
return;
}
_hlsQueue.push(callback);
if (_hlsState === "loading") return;
_hlsState = "loading";
const script = document.createElement("script");
script.src = "/shared/hls-1.6.16/hls.min.js";
script.onload = function () {
_hlsState = "ready";
const q = _hlsQueue;
_hlsQueue = [];
for (let i = 0; i < q.length; i++) q[i]();
};
script.onerror = function () {
_hlsState = "idle";
const q = _hlsQueue;
_hlsQueue = [];
// Fall through — _activatePlayer will use stream_url since Hls is undefined
for (let i = 0; i < q.length; i++) q[i]();
};
document.head.appendChild(script);
}
function _isHlsUrl(url) {
return typeof url === "string" && /\.m3u8(\?|$)/i.test(url);
}
function _activatePlayer(btn) {
const url = btn.dataset.streamUrl;
const hlsUrl = btn.dataset.hlsUrl;
const isAudio = btn.dataset.audioOnly === "true";
const directStream = btn.dataset.directStream === "true";
const player = document.createElement(isAudio ? "audio" : "video");
player.controls = true;
player.autoplay = true;
player.className = "media-player";
// Held so the error handler can tear the instance down before the player
// node is replaced — otherwise its listeners/loader timers run detached.
let hls = null;
// Prefer direct stream when the source supports it; fall back to HLS
// only when transcoding is needed.
if (directStream && url) {
player.src = url;
} else if (
hlsUrl &&
!isAudio &&
typeof Hls !== "undefined" &&
Hls.isSupported()
) {
hls = new Hls();
hls.loadSource(hlsUrl);
hls.attachMedia(player);
} else if (
hlsUrl &&
!isAudio &&
player.canPlayType("application/vnd.apple.mpegurl")
) {
player.src = hlsUrl;
} else {
player.src = url;
}
player.addEventListener("error", function () {
if (hls) {
hls.destroy();
hls = null; // media error events can repeat — never double-destroy
}
const card = player.closest(".media-embed");
const titleEl = card ? card.querySelector(".media-card-title") : null;
const label = titleEl ? ": " + titleEl.textContent : "";
const err = document.createElement("div");
err.className = "media-player-error";
err.setAttribute("role", "alert");
err.textContent = "Failed to load stream" + label;
const retry = document.createElement("button");
retry.className = "media-play-btn";
retry.type = "button";
retry.dataset.streamUrl = url;
retry.dataset.hlsUrl = hlsUrl || "";
retry.dataset.audioOnly = String(isAudio);
retry.dataset.directStream = String(directStream);
retry.setAttribute("aria-label", "Retry" + label);
retry.appendChild(document.createTextNode("▶ Retry"));
const container = document.createElement("div");
container.appendChild(err);
container.appendChild(retry);
player.replaceWith(container);
});
btn.replaceWith(player);
}
// Activate a clicked/Enter-pressed play button: show the loading affordance,
// then ensure hls.js is loaded before swapping in the player when the source
// needs it. The pane wires this from a root-scoped this.el listener.
function activateMediaPlayButton(btn) {
btn.disabled = true;
const labelEl = btn.querySelector("span:last-child");
if (labelEl) {
labelEl.textContent = "Loading…";
} else {
btn.textContent = "▶ Loading…";
}
const hlsUrl = btn.dataset.hlsUrl;
const isAudio = btn.dataset.audioOnly === "true";
// If HLS URL present and not audio, ensure hls.js is loaded first
if (hlsUrl && !isAudio && _isHlsUrl(hlsUrl)) {
_loadHls(function () {
_activatePlayer(btn);
});
} else {
_activatePlayer(btn);
}
}
function buildMediaCard(item) {
const card = document.createElement("div");
card.className = "media-card";
@@ -3244,10 +3403,18 @@ function createInteractivePane(root, wsId, opts) {
warningTarget(pane) {
return pane.messagesEl;
},
// MCP re-consent surfaces inline in the pane card; the console has no
// settings-gear badge to drive (a future console consent surface can hook
// here).
onConsentDetected() {},
// MCP re-consent surfaces inline in the pane card; the STANDALONE additionally
// drives a Manage-row attention badge — bridged through the TS_APP seam so the
// shared factory stays deployment-agnostic (the console doesn't define the
// hook, so this stays a no-op there).
onConsentDetected(server) {
if (
window.TS_APP &&
typeof window.TS_APP.onConsentDetected === "function"
) {
window.TS_APP.onConsentDetected(server);
}
},
};
const pane = new Pane(wsId, {
+6
View File
@@ -122,6 +122,12 @@ export function openPopupMenu(anchor, items, opts) {
}
btn.addEventListener("click", () => {
close();
// close() just removed the focused item from the DOM — without a
// handoff, focus falls to <body>, and a dialog opened by the action
// captures body as its opener (so its close-restore no-ops). Hand
// focus to the menu's return target before the action runs.
const back = opts.returnFocusEl || anchor;
if (back && back.isConnected) back.focus();
try {
item.action();
} catch (e) {
+103
View File
@@ -39,6 +39,24 @@ export function glyph(state) {
return el;
}
/** A small count chip for a Manage row/group head glyph + count (never colour
* alone), DS warn vocabulary (shell.css `.rail-badge`). The count rides the
* text; the glyph is aria-hidden because the supplied `label` already names
* the condition for assistive tech. Returns a detached span the caller mounts. */
function badge(count, label) {
const el = document.createElement("span");
el.className = "rail-badge";
const g = document.createElement("span");
g.className = "rail-badge-glyph";
g.setAttribute("aria-hidden", "true");
g.textContent = "⚠"; // ⚠ — pairs the colour with a glyph (chip-contrast rule)
const n = document.createElement("b");
n.textContent = String(count);
el.append(g, n);
if (label) el.setAttribute("aria-label", label);
return el;
}
/** Derive a node's overall state glyph from its workstream mix + health. */
function nodeState(info) {
if (!info.reachable) return "error";
@@ -325,6 +343,80 @@ export function mountRail(sections, caps) {
// ---- Manage section (admin IA → collapsible discovery groups) --------------
// Generic Manage-row badge state. A subsystem (e.g. the standalone consent
// badge) drives a count onto a tab row by KEY via `setRowBadge`; rail.js stays
// agnostic about what the count means. Kept module-level so a `mountManage`
// rebuild (the IA is static, but the section re-mounts on shell init) re-applies
// the live counts rather than dropping them. `null` label = clear.
const _rowBadges = new Map(); // tabKey -> { count, label }
// Rebuilt each mount: the row <button> + owning group key for every tab. The
// group's head <button> (where a collapsed group surfaces its summed badge —
// a collapsed group hides its rows, so the head must carry the signal) is
// looked up via _groupEls, not duplicated here.
let _rowEls = new Map(); // tabKey -> { row, group }
let _groupEls = new Map(); // group key -> { head, tabKeys: [] }
/** Paint (or clear) the badge slot inside a host button, keyed by a stable
* `.rail-badge` child so repeated calls replace rather than stack. */
function _applyBadge(host, count, label) {
if (!host) return;
const existing = host.querySelector(":scope > .rail-badge");
if (!count) {
if (existing) existing.remove();
return;
}
const fresh = badge(count, label);
if (existing) host.replaceChild(fresh, existing);
else host.append(fresh);
}
/** Sum the live badge counts for a group's tabs drives the head badge so a
* COLLAPSED group still shows that something inside it needs attention. */
function _groupCount(groupKey) {
const grp = _groupEls.get(groupKey);
if (!grp) return 0;
let total = 0;
for (const tabKey of grp.tabKeys) {
const b = _rowBadges.get(tabKey);
if (b) total += b.count;
}
return total;
}
/**
* Generic Manage-row badge hook. `setRowBadge(tabKey, count, label?)` stamps a
* count chip on that tab's row and, so the signal survives a collapsed group,
* mirrors the group's total onto its head. `count` 0 (or falsy) clears the row.
* Drives off the refs `mountManage` registered; a no-op before the first mount
* (the consent subsystem re-drives it after `_refreshConsentBadge`).
*
* rail.js owns the MECHANISM only callers own the meaning (no consent specifics
* here), matching the rail's seam-driven posture.
*/
export function setRowBadge(tabKey, count, label) {
const n = Number(count) || 0;
if (n > 0) _rowBadges.set(tabKey, { count: n, label: label || "" });
else _rowBadges.delete(tabKey);
const ref = _rowEls.get(tabKey);
if (!ref) return; // not mounted yet (or gated away) — state is kept for remount
_applyBadge(ref.row, n, label);
// The collapsed-group head mirrors the group's running total + its own label.
const total = _groupCount(ref.group);
const grp = _groupEls.get(ref.group);
_applyBadge(
grp && grp.head,
total,
total ? total + " in " + ref.group + " awaiting attention" : "",
);
}
/** Re-stamp every stored badge after a (re)mount rebuilt the row/head refs.
* `setRowBadge` self-guards a missing ref and recomputes each head total, so
* replaying the stored rows lands both rows and heads at their correct sums. */
function _reapplyBadges() {
for (const [tabKey, b] of _rowBadges) setRowBadge(tabKey, b.count, b.label);
}
/**
* Build the rail's Manage groups from the admin IA seam (admin.js exposes
* `window.TS_ADMIN`). Each group is a collapsible `.grp` whose head toggles
@@ -374,6 +466,9 @@ export function mountManage(root, paneManager) {
const activeTab = adminOpen && TS.getActiveTab ? TS.getActiveTab() : null;
const rowByTab = new Map(); // tab -> its row <button>, for active-state sync
// Rebuild the badge ref maps for this mount (the previous DOM is gone).
_rowEls = new Map();
_groupEls = new Map();
ia.forEach((group) => {
const tabs = group.tabs.filter((t) => allowed(t.tab));
@@ -404,6 +499,8 @@ export function mountManage(root, paneManager) {
count.className = "gcount";
count.textContent = String(tabs.length);
head.append(chev, name, count);
// Register the head so a collapsed group can carry its tabs' badge total.
_groupEls.set(group.group, { head, tabKeys: tabs.map((t) => t.tab) });
const items = document.createElement("div");
items.className = "grp-items";
@@ -421,6 +518,9 @@ export function mountManage(root, paneManager) {
if (TS.openTab) TS.openTab(t.tab);
});
rowByTab.set(t.tab, row);
// Register the row + its owning group for the badge hook (the group's
// head element is resolved through _groupEls when needed).
_rowEls.set(t.tab, { row, group: group.group });
items.append(row);
}
@@ -434,6 +534,9 @@ export function mountManage(root, paneManager) {
root.append(grp);
});
// Re-apply any live row badges a subsystem set before/across this (re)mount.
_reapplyBadges();
// Single writer for the Manage active-row: the row for the current admin tab
// carries `.active`. admin.js notifies on every switchAdminTab; seed it here
// for an already-open (restored) Admin pane.
+87 -8
View File
@@ -723,6 +723,10 @@
.tab-menu {
animation: none;
}
.persona-btn,
.persona-led {
transition: none;
}
}
/* pane host ONE pane visible per tab (no split; the mock's 2-up was a
@@ -829,33 +833,76 @@
}
/* ===== Dashboard session launcher persona toggle (coordinator | interactive).
A console-dashboard control; lives here because the L-shell loads shell.css. */
A console-dashboard control; lives here because the L-shell loads shell.css.
The active option wears its KIND, not a neutral highlight: amber for
coordinator, cyan for interactive the same vocabulary as the pane-head
.ptag chips and the rail's session rows, so "which kind am I starting"
reads at a glance. Tints are 15% (sub-0.10 washes out at chip size) and
colour is never alone: the kind LED + label weight carry the state too. */
.launcher-personas {
display: inline-flex;
gap: 2px;
margin-bottom: 10px;
padding: 2px;
border: 1px solid var(--hair);
padding: 3px;
border: 1px solid var(--hair-2);
border-radius: var(--r-sm);
background: var(--panel-2);
background: var(--bg); /* recessed track — the .seg precedent */
}
.persona-btn {
padding: 4px 12px;
display: inline-flex;
align-items: center;
gap: 7px;
padding: 5px 14px;
border: 0;
background: none;
color: var(--ink-3);
font-size: 12px;
font-weight: 500;
font-family: var(--font-ui);
/* r-sm, matching the shared :focus-visible rule below a literal here
would make the corner radius pop on keyboard focus */
border-radius: var(--r-sm);
cursor: pointer;
transition:
background 0.12s,
color 0.12s;
}
.persona-btn:hover {
color: var(--ink);
}
.persona-led {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--ink-4);
opacity: 0.35;
flex-shrink: 0;
transition:
background 0.12s,
opacity 0.12s,
box-shadow 0.12s;
}
.persona-btn.active {
background: var(--panel);
color: var(--ink);
box-shadow: inset 0 0 0 1px var(--hair-2);
font-weight: 600;
}
.persona-btn--coord.active {
background: color-mix(in srgb, var(--accent) 15%, transparent);
color: var(--accent);
}
.persona-btn--coord.active .persona-led {
background: var(--accent);
opacity: 1;
box-shadow: 0 0 6px var(--accent-glow-strong);
}
.persona-btn--int.active {
background: color-mix(in srgb, var(--cyan) 15%, transparent);
color: var(--cyan);
}
.persona-btn--int.active .persona-led {
background: var(--cyan);
opacity: 1;
box-shadow: 0 0 6px var(--cyan-glow);
}
/* persona tag in the saved-sessions table shares the rail chip base
(.row .tag above); only no-wrap + the INT colour differ. */
@@ -950,6 +997,38 @@
box-shadow: inset 2px 0 0 var(--ink-4);
}
/* Manage-row attention badge (rail.js `setRowBadge` / `badge`) a small count
chip pinned to the row tail (and to a COLLAPSED group's head, so a hidden row
never hides the signal). DS warn vocabulary: the glyph PLUS the tinted
chip carry the meaning (never colour alone), so it reads at chip size and
flips themes by construction (the --warn family is defined per theme). */
.rail-badge {
display: inline-flex;
align-items: center;
gap: 3px;
margin-left: auto; /* push to the row/head tail past the flex-1 label */
padding: 0 5px;
border-radius: var(--r-sm);
background: var(--warn-tint);
border: 1px solid var(--warn-tint-border);
color: var(--warn);
font-size: 10px;
line-height: 1.5;
font-variant-numeric: tabular-nums;
flex: none;
}
/* The group head already auto-spaces with its own gap, and its --warn count
should not fight the dim --ink-4 tab count beside it. */
.grp-head .rail-badge {
margin-left: 6px;
}
.rail-badge .rail-badge-glyph {
font-weight: 700;
}
.rail-badge b {
font-weight: 600;
}
/* ===== Admin pane adopts #view-admin (the 18 tabpanels). The in-pane
sidebar is retired (the rail's Manage groups navigate), so #view-admin fills
the pane body and the content host renders full-width. ===== */
+6 -2
View File
@@ -24,7 +24,7 @@
========================================================================== */
import { PaneManager, ShellPane, openPopupMenu } from "./pane.js";
import { mountRail, mountManage, glyph } from "./rail.js";
import { mountRail, mountManage, glyph, setRowBadge } from "./rail.js";
import { authFetch } from "./auth.js";
// The interactive pane is a real ES module beside us in /shared (step 5a) — the
// shell imports it directly, and it exists in every deployment. The coordinator
@@ -880,7 +880,11 @@ async function mountShell() {
const p = pm.getPane("interactive", wsId);
if (p && p._ctl && p._ctl.markDead) p._ctl.markDead();
};
window.TS_SHELL = { panes: pm, caps, notifySessionClosed };
// `setRowBadge` lets a classic-script subsystem (the standalone consent badge
// in ui/static/app.js) stamp a count chip on a Manage row without importing the
// ESM rail module — the shell is its module bridge. Generic: the rail owns the
// chip mechanism, the caller owns what the count means.
window.TS_SHELL = { panes: pm, caps, notifySessionClosed, setRowBadge };
// Login fan-out: app.js owns the single window.onLoginSuccess (the Tier-1
// reconnect, set at load). Wrap it in a tiny registry so EVERY conversational
+22
View File
@@ -21,11 +21,33 @@ function _displayToast(el, message, type) {
el.textContent = message;
el.classList.remove("toast-error");
if (type === "error") el.classList.add("toast-error");
// A document-modal <dialog> owns the top layer, which stacks above every
// z-index — a toast fired while one is open (e.g. "Token copied" over the
// token-created dialog) would render underneath. Promote to a manual
// popover ONLY for that case: popovers join the top layer above the open
// dialog, while the everyday path keeps the fade transition (a persistent
// popover attribute would impose UA display:none and kill it).
if ("showPopover" in el && document.querySelector("dialog:modal")) {
el.popover = "manual";
try {
el.showPopover();
} catch (e) {
/* already showing */
}
}
el.classList.add("show");
_toastShowing = true;
if (_toastTimer) clearTimeout(_toastTimer);
_toastTimer = setTimeout(function () {
el.classList.remove("show");
if (el.popover) {
try {
el.hidePopover();
} catch (e) {
/* already hidden */
}
el.removeAttribute("popover"); // restore the classic fade path
}
_toastShowing = false;
_toastTimer = null;
if (_toastQueue.length) {
+1 -1
View File
@@ -6,7 +6,7 @@
"properties": {
"ws_id": {
"type": "string",
"description": "Workstream id to cancel."
"description": "Workstream id to cancel — exactly 32 hex chars, copied verbatim. Unknown or malformed ids error with did-you-mean suggestions; nothing is ever guessed."
}
},
"required": ["ws_id"]
+1 -1
View File
@@ -6,7 +6,7 @@
"properties": {
"ws_id": {
"type": "string",
"description": "Workstream id to soft-close."
"description": "Workstream id to soft-close — exactly 32 hex chars, copied verbatim. Unknown or malformed ids error with did-you-mean suggestions; nothing is ever guessed."
},
"reason": {
"type": "string",
+1 -1
View File
@@ -6,7 +6,7 @@
"properties": {
"ws_id": {
"type": "string",
"description": "Workstream id to hard-delete."
"description": "Workstream id to hard-delete — exactly 32 hex chars, copied verbatim. Unknown or malformed ids error with did-you-mean suggestions; nothing is ever guessed (this verb is irreversible)."
}
},
"required": ["ws_id"]
+1 -1
View File
@@ -6,7 +6,7 @@
"properties": {
"ws_id": {
"type": "string",
"description": "Workstream id to inspect."
"description": "Workstream id to inspect — exactly 32 hex chars, copied verbatim from spawn_batch / list_workstreams results. Unknown or malformed ids error with did-you-mean suggestions and a roster of your children; nothing is ever guessed."
},
"message_limit": {
"type": "integer",
+1 -1
View File
@@ -6,7 +6,7 @@
"properties": {
"ws_id": {
"type": "string",
"description": "Workstream id that should receive the message."
"description": "Workstream id that should receive the message — exactly 32 hex chars, copied verbatim from spawn_batch / list_workstreams results (display names are not addresses)."
},
"message": {
"type": "string",
+3 -3
View File
@@ -1,13 +1,13 @@
{
"name": "wait_for_workstream",
"description": "Block until one or all named child workstreams reach a terminal state. Prefer this over busy-polling inspect_workstream after a fan-out: the tool absorbs the wait, so you get one call + one result regardless of duration. Returns `{results: {ws_id: {state, tokens, updated, message, truncated}}, elapsed, complete, mode}` — `results` is keyed by ws_id (not top-level), `complete` is true when the wait condition fired before the timeout. `message` is the child's last assistant text for `idle`/`error`, a short sentinel for `closed`/`denied`, and `null` for non-terminal rows so a follow-up read knows which children still need work; capped at 10 KiB UTF-8, with `truncated=true` when the cap fires (call inspect_workstream for the rest). Real terminal states: `idle`, `error`, `closed`, `deleted` (the last is unobservable since hard-delete cascades the row out of storage). `mode='any'` returns when the first child hits a real terminal — a `denied` id alone never satisfies the condition, so a typo'd / foreign / nonexistent id can't false-positive a wait. `mode='all'` returns once every id has settled (real terminal OR denied). Cross-tenant guard: only the coordinator's own children (or itself) are visible; everything else is reported `state='denied'`. Capped at 32 ws_ids and 600s; both overflows error rather than silently truncating.",
"description": "Block until one or all named child workstreams reach a terminal state. Prefer this over busy-polling inspect_workstream after a fan-out: the tool absorbs the wait, so you get one call + one result regardless of duration. Returns `{results: {ws_id: {state, tokens, updated, name, message, truncated}}, elapsed, complete, mode}` — `results` is keyed by ws_id (not top-level), `complete` is true when the wait condition fired before the timeout. `message` is the child's last assistant text for `idle`/`error`, a short sentinel for `closed`/`not_found`, and `null` for non-terminal rows so a follow-up read knows which children still need work; capped at 10 KiB UTF-8, with `truncated=true` when the cap fires (call inspect_workstream for the rest). Real terminal states: `idle`, `error`, `closed`, `deleted` (the last is unobservable since hard-delete cascades the row out of storage). `mode='any'` returns when the first child hits a real terminal; `mode='all'` returns once every id is real-terminal. Ids are validated up front — ws_ids are exactly 32 hex chars, copy them VERBATIM from spawn_batch/list_workstreams results; a malformed id (truncated/garbled/non-hex) errors immediately with `did_you_mean` suggestions and a roster of your children, and an id that can't be observed (foreign / nonexistent / hard-deleted mid-wait) aborts the wait on the tick that sees it with `state='not_found'` plus top-level `error`/`not_found`/`children` fields. When that happens, fix the id and re-issue — do NOT assume the child is dead; check `did_you_mean` or re-list. Child display names are labels, not addresses; always target ids. Capped at 32 ws_ids and 600s; both overflows error rather than silently truncating.",
"parameters": {
"type": "object",
"properties": {
"ws_ids": {
"type": "array",
"items": { "type": "string" },
"description": "Workstream ids to wait on. Accepts 1 or more; capped at 32 per call."
"description": "Workstream ids to wait on (each exactly 32 hex chars, copied verbatim from spawn_batch / list_workstreams results). Accepts 1 or more; capped at 32 per call. Malformed ids fail the call before any waiting; well-formed ids that can't be observed abort it on the first tick — both return did-you-mean suggestions."
},
"timeout": {
"type": "number",
@@ -17,7 +17,7 @@
"type": "string",
"enum": ["any", "all"],
"default": "any",
"description": "'any' (default) returns when the first child reaches a real terminal state (idle / error / closed / deleted); 'all' returns once every named child has settled (real terminal OR denied). A pure-denied list with mode='any' short-circuits to complete=false rather than spinning the timeout."
"description": "'any' (default) returns when the first child reaches a real terminal state (idle / error / closed / deleted); 'all' returns once every named child is real-terminal. An id that can't be observed (foreign / nonexistent / deleted mid-wait) aborts the wait immediately with state='not_found' and a top-level error, regardless of mode."
},
"since": {
"type": "object",
+194 -463
View File
@@ -160,7 +160,6 @@ window.onThemeChange = function (next) {
// 9. New workstream modal
// ===========================================================================
let _newWsTrapHandler = null;
let _forkFromWsId = "";
// Staged files for the new-workstream modal. Distinct from the pane's
@@ -266,35 +265,46 @@ function _isAttachmentAllowed(file) {
return false;
}
// In-dialog error strip (sh-alert). Empty message clears + hides; a set
// message also scrolls into view — the alert sits at the top of the
// scrollable body while the submit lives in the pinned foot.
function _newWsError(msg) {
const el = document.getElementById("new-ws-error");
el.textContent = msg || "";
if (msg) {
el.classList.add("is-visible");
if (el.scrollIntoView) el.scrollIntoView({ block: "nearest" });
} else {
el.classList.remove("is-visible");
}
}
function _newWsAddFiles(files) {
const errEl = document.getElementById("new-ws-error");
for (let i = 0; i < files.length; i++) {
const f = files[i];
if (_newWsStagedFiles.length >= _NEW_WS_MAX_FILES) {
errEl.textContent =
"At most " + _NEW_WS_MAX_FILES + " attachments per workstream";
errEl.style.display = "block";
_newWsError(
"At most " + _NEW_WS_MAX_FILES + " attachments per workstream",
);
return;
}
if (!_isAttachmentAllowed(f)) {
errEl.textContent =
_newWsError(
"Unsupported file type: " +
f.name +
" (allowed: png/jpeg/gif/webp images, text)";
errEl.style.display = "block";
f.name +
" (allowed: png/jpeg/gif/webp images, text)",
);
return;
}
const isImage = (f.type || "").indexOf("image/") === 0;
const cap = isImage ? _NEW_WS_IMAGE_CAP : _NEW_WS_TEXT_CAP;
if (f.size > cap) {
errEl.textContent =
f.name + " exceeds the " + _formatAttachSize(cap) + " cap";
errEl.style.display = "block";
_newWsError(f.name + " exceeds the " + _formatAttachSize(cap) + " cap");
return;
}
_newWsStagedFiles.push(f);
}
errEl.style.display = "none";
_newWsError("");
_newWsRenderChips();
}
@@ -304,35 +314,27 @@ function newWorkstream() {
function showNewWsModal(forkFromWsId) {
_forkFromWsId = forkFromWsId || "";
const overlay = document.getElementById("new-ws-overlay");
overlay.style.display = "flex";
document.body.style.overflow = "hidden";
const dlg = document.getElementById("new-ws-dialog");
// Update title and button text based on mode
// Update title, plate and button text based on mode
const titleEl = document.getElementById("new-ws-title");
const tagEl = document.getElementById("new-ws-tag");
const submitBtn = document.getElementById("new-ws-submit");
if (_forkFromWsId) {
titleEl.textContent = "Fork Workstream";
titleEl.textContent = "Fork workstream";
tagEl.textContent = "WS-FORK";
submitBtn.textContent = "Fork";
} else {
titleEl.textContent = "New Workstream";
titleEl.textContent = "New workstream";
tagEl.textContent = "WS-NEW";
submitBtn.textContent = "Create";
}
// Hide skill dropdown when forking (not relevant — fork copies history)
const skillLabel = document.querySelector('label[for="new-ws-skill"]');
const skillSelect = document.getElementById("new-ws-skill");
if (_forkFromWsId) {
if (skillLabel) skillLabel.style.display = "none";
if (skillSelect) skillSelect.style.display = "none";
} else {
if (skillLabel) skillLabel.style.display = "";
if (skillSelect) skillSelect.style.display = "";
}
overlay.onclick = function (e) {
if (e.target === overlay) hideNewWsModal();
};
if (skillLabel) skillLabel.hidden = !!_forkFromWsId;
if (skillSelect) skillSelect.hidden = !!_forkFromWsId;
// Populate model dropdown
const modelSelect = document.getElementById("new-ws-model");
@@ -399,10 +401,7 @@ function showNewWsModal(forkFromWsId) {
document.getElementById("new-ws-name").value = "";
const initEl = document.getElementById("new-ws-initial-message");
if (initEl) initEl.value = "";
const errEl = document.getElementById("new-ws-error");
errEl.style.display = "none";
errEl.textContent = "";
submitBtn.disabled = false;
_newWsError("");
// Reset attachment staging. Forks don't carry attachments —
// disable the attach UI in that case (the fork inherits its
@@ -411,7 +410,7 @@ function showNewWsModal(forkFromWsId) {
const attachRow = document.getElementById("new-ws-attach-row");
const attachInput = document.getElementById("new-ws-attach-input");
const attachBtn = document.getElementById("new-ws-attach-btn");
if (attachRow) attachRow.style.display = _forkFromWsId ? "none" : "";
if (attachRow) attachRow.hidden = !!_forkFromWsId;
if (attachInput) attachInput.value = "";
_newWsRenderChips();
if (attachBtn && attachInput) {
@@ -426,71 +425,21 @@ function showNewWsModal(forkFromWsId) {
};
}
document.getElementById("new-ws-cancel").onclick = hideNewWsModal;
submitBtn.onclick = submitNewWs;
_newWsTrapHandler = function (e) {
if (e.key === "Escape") {
e.preventDefault();
hideNewWsModal();
return;
}
if (
e.key === "Enter" &&
e.target.tagName !== "TEXTAREA" &&
e.target.tagName !== "SELECT"
) {
e.preventDefault();
submitNewWs();
return;
}
if (e.key !== "Tab") return;
const box = document.getElementById("new-ws-box");
const focusable = box.querySelectorAll(
'input, select, button, [tabindex]:not([tabindex="-1"])',
);
if (!focusable.length) return;
const first = focusable[0],
last = focusable[focusable.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
};
document.addEventListener("keydown", _newWsTrapHandler);
setTimeout(function () {
document.getElementById("new-ws-name").focus();
}, 50);
window.TurnstoneHatch.openDialog(dlg, {
onClose: function () {
_forkFromWsId = "";
},
});
}
function hideNewWsModal() {
_forkFromWsId = "";
document.getElementById("new-ws-overlay").style.display = "none";
document.body.style.overflow = "";
if (_newWsTrapHandler) {
document.removeEventListener("keydown", _newWsTrapHandler);
_newWsTrapHandler = null;
}
// Hand focus back to the shell's [+] new-session affordance. The old
// #new-tab-btn went with the retired split-pane tab bar — the unguarded
// lookup threw on every modal close after that.
const back = document.querySelector(".tab-add");
if (back) back.focus();
const d = document.getElementById("new-ws-dialog");
if (d.open) d.close();
}
function submitNewWs() {
const submitBtn = document.getElementById("new-ws-submit");
if (submitBtn.disabled) return;
submitBtn.disabled = true;
submitBtn.textContent = _forkFromWsId ? "Forking\u2026" : "Creating\u2026";
const dlg = document.getElementById("new-ws-dialog");
const body = {};
const name = document.getElementById("new-ws-name").value.trim();
const model = document.getElementById("new-ws-model").value.trim();
@@ -507,8 +456,8 @@ function submitNewWs() {
if (_forkFromWsId) body.resume_ws = _forkFromWsId;
if (initial_message) body.initial_message = initial_message;
const errEl = document.getElementById("new-ws-error");
errEl.style.display = "none";
_newWsError("");
window.TurnstoneHatch.setBusy(dlg, true);
let fetchOpts;
const staged = _forkFromWsId ? [] : _newWsStagedFiles.slice();
@@ -533,11 +482,9 @@ function submitNewWs() {
return r.json();
})
.then(function (data) {
window.TurnstoneHatch.setBusy(dlg, false);
if (data.error) {
errEl.textContent = data.error;
errEl.style.display = "block";
submitBtn.disabled = false;
submitBtn.textContent = _forkFromWsId ? "Fork" : "Create";
_newWsError(data.error);
return;
}
if (data.ws_id) {
@@ -548,12 +495,12 @@ function submitNewWs() {
}
})
.catch(function () {
errEl.textContent = _forkFromWsId
? "Failed to fork workstream"
: "Failed to create workstream";
errEl.style.display = "block";
submitBtn.disabled = false;
submitBtn.textContent = _forkFromWsId ? "Fork" : "Create";
window.TurnstoneHatch.setBusy(dlg, false);
_newWsError(
_forkFromWsId
? "Failed to fork workstream"
: "Failed to create workstream",
);
});
}
@@ -799,43 +746,43 @@ let _wsTable = null;
// deferred ES module now, so its bridged globals (SavedColumns,
// createSavedTable) don't exist yet while this classic file parses.
function _initSavedWsTable() {
const WS_COLUMNS = [
SavedColumns.name(),
SavedColumns.model(),
SavedColumns.count("message_count", "MSGS"),
SavedColumns.ctx(),
SavedColumns.last(),
SavedColumns.id(),
];
_wsTable = createSavedTable({
headerEl: document.getElementById("ws-saved-colheaders"),
bodyEl: document.getElementById("dashboard-saved-cards"),
filterEl: document.getElementById("ws-filter"),
footerEl: document.getElementById("ws-saved-footer"),
paginationEl: document.getElementById("ws-pagination"),
columns: WS_COLUMNS,
noun: "workstream",
emptyText: "No saved workstreams",
activateLabel: function (s) {
return "Resume: " + (s.alias || s.title || s.ws_id);
},
onActivate: function (s) {
dashboardResumeSession(s.ws_id);
},
delete: {
idPrefix: "ws-delete",
buttonId: "ws-delete-btn",
buildDeleteRequest: function (wsId) {
return {
url: "/v1/api/workstreams/" + encodeURIComponent(wsId) + "/delete",
options: { method: "POST" },
};
const WS_COLUMNS = [
SavedColumns.name(),
SavedColumns.model(),
SavedColumns.count("message_count", "MSGS"),
SavedColumns.ctx(),
SavedColumns.last(),
SavedColumns.id(),
];
_wsTable = createSavedTable({
headerEl: document.getElementById("ws-saved-colheaders"),
bodyEl: document.getElementById("dashboard-saved-cards"),
filterEl: document.getElementById("ws-filter"),
footerEl: document.getElementById("ws-saved-footer"),
paginationEl: document.getElementById("ws-pagination"),
columns: WS_COLUMNS,
noun: "workstream",
emptyText: "No saved workstreams",
activateLabel: function (s) {
return "Resume: " + (s.alias || s.title || s.ws_id);
},
onClose: function () {
loadDashboard();
onActivate: function (s) {
dashboardResumeSession(s.ws_id);
},
},
});
delete: {
idPrefix: "ws-delete",
buttonId: "ws-delete-btn",
buildDeleteRequest: function (wsId) {
return {
url: "/v1/api/workstreams/" + encodeURIComponent(wsId) + "/delete",
options: { method: "POST" },
};
},
onClose: function () {
loadDashboard();
},
},
});
}
// HTML inline-onclick wrappers — keep the global names the existing markup
@@ -853,12 +800,6 @@ function toggleSelectAll() {
function confirmWsDeleteSelection() {
_wsTable.controller.confirmSelection();
}
function cancelWsDelete() {
_wsTable.controller.closeModal();
}
function confirmWsDelete() {
_wsTable.controller.confirm();
}
// --- Workstream title management ---
@@ -885,7 +826,7 @@ function refreshWorkstreamTitle(optWsId) {
});
}
let _editTitleTrap = null;
let _editTitleWsId = null;
function editWorkstreamTitle(optWsId) {
const wsId = optWsId || getCurrentWsId();
@@ -893,55 +834,44 @@ function editWorkstreamTitle(optWsId) {
const ws = workstreams[wsId];
const currentTitle = ws && ws.name ? ws.name : "";
const overlay = document.getElementById("edit-title-overlay");
// Pin the target: submit must rename THIS workstream, not whichever
// pane is active by then (menu-rename on a background tab).
_editTitleWsId = wsId;
const dlg = document.getElementById("edit-title-dialog");
const input = document.getElementById("edit-title-input");
input.value = currentTitle;
overlay.style.display = "flex";
overlay.onclick = function (e) {
if (e.target === overlay) cancelEditTitle();
};
// Focus trap + Escape
if (_editTitleTrap) document.removeEventListener("keydown", _editTitleTrap);
_editTitleTrap = function (e) {
if (e.key === "Escape") {
// A rename is a styled prompt(): Enter submits. Escape is the native
// dialog cancel; hatch.js owns the trap and the data-close buttons.
input.onkeydown = function (e) {
if (e.key === "Enter") {
e.preventDefault();
cancelEditTitle();
return;
}
if (e.key === "Tab") {
const box = document.getElementById("edit-title-box");
const focusable = box.querySelectorAll("input, button");
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
submitEditTitle();
}
};
document.addEventListener("keydown", _editTitleTrap);
setTimeout(function () {
input.focus();
input.select();
}, 50);
document.getElementById("edit-title-save").onclick = submitEditTitle;
window.TurnstoneHatch.openDialog(dlg, {
onClose: function () {
_editTitleWsId = null;
},
});
// select() sets the selection but does NOT move focus (per spec) — without
// this, focus stays on the header ✕ and Enter closes instead of submitting.
input.focus();
input.select();
}
function cancelEditTitle() {
document.getElementById("edit-title-overlay").style.display = "none";
if (_editTitleTrap) {
document.removeEventListener("keydown", _editTitleTrap);
_editTitleTrap = null;
}
const d = document.getElementById("edit-title-dialog");
if (d.open) d.close();
}
function submitEditTitle() {
const wsId = getCurrentWsId();
const wsId = _editTitleWsId;
if (!wsId) return;
const dlg = document.getElementById("edit-title-dialog");
// Enter arrives straight from the input's keydown — the busy capture
// guard only swallows clicks, so re-submits are refused here.
if (dlg.hasAttribute("data-busy")) return;
const input = document.getElementById("edit-title-input");
const newTitle = input.value.trim();
if (!newTitle) {
@@ -951,6 +881,7 @@ function submitEditTitle() {
const url = "/v1/api/workstreams/" + encodeURIComponent(wsId) + "/title";
window.TurnstoneHatch.setBusy(dlg, true);
authFetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -961,6 +892,7 @@ function submitEditTitle() {
return r.json();
})
.then(function (data) {
window.TurnstoneHatch.setBusy(dlg, false);
cancelEditTitle();
// Optimistic update — SSE ws_rename will confirm
const nameEls = document.querySelectorAll(
@@ -973,6 +905,7 @@ function submitEditTitle() {
showToast("Title updated", "success");
})
.catch(function (err) {
window.TurnstoneHatch.setBusy(dlg, false);
showToast(err.message || "Failed to set title", "error");
});
}
@@ -980,7 +913,6 @@ function submitEditTitle() {
// --- Workstream deletion ---
let _pendingDeleteWsId = null;
let _deleteWsTrap = null;
function confirmDeleteWorkstream(optWsId) {
const wsId = optWsId || getCurrentWsId();
@@ -990,52 +922,34 @@ function confirmDeleteWorkstream(optWsId) {
const name = ws && ws.name ? ws.name : wsId.substring(0, 12);
_pendingDeleteWsId = wsId;
const overlay = document.getElementById("delete-ws-overlay");
const msg = document.getElementById("delete-ws-message");
msg.textContent = 'Delete "' + name + '"? This cannot be undone.';
overlay.style.display = "flex";
// Focus trap + Escape
if (_deleteWsTrap) document.removeEventListener("keydown", _deleteWsTrap);
_deleteWsTrap = function (e) {
if (e.key === "Escape") {
e.preventDefault();
cancelDeleteWs();
return;
}
if (e.key === "Tab") {
const box = document.getElementById("delete-ws-box");
const focusable = box.querySelectorAll("button");
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
};
document.addEventListener("keydown", _deleteWsTrap);
const cancelBtn = overlay.querySelector("button");
if (cancelBtn) cancelBtn.focus();
document.getElementById("delete-ws-message").textContent =
'Delete "' + name + '"? This cannot be undone.';
document.getElementById("delete-ws-confirm").onclick = executeDeleteWs;
// Cancel carries the autofocus — Enter on a freshly-opened destructive
// confirm must not fire the action (the console confirm-dialog rule).
window.TurnstoneHatch.openDialog(
document.getElementById("delete-ws-dialog"),
{
onClose: function () {
_pendingDeleteWsId = null;
},
},
);
}
function cancelDeleteWs() {
_pendingDeleteWsId = null;
document.getElementById("delete-ws-overlay").style.display = "none";
if (_deleteWsTrap) {
document.removeEventListener("keydown", _deleteWsTrap);
_deleteWsTrap = null;
}
const d = document.getElementById("delete-ws-dialog");
if (d.open) d.close();
}
function executeDeleteWs() {
const wsId = _pendingDeleteWsId;
if (!wsId) return;
cancelDeleteWs();
const dlg = document.getElementById("delete-ws-dialog");
// Hold the dialog open under the busy lock until the request resolves —
// the revoke confirm's pattern. On failure the user keeps their context
// (retry or cancel) instead of a toast over an already-closed dialog.
window.TurnstoneHatch.setBusy(dlg, true);
const url = "/v1/api/workstreams/" + encodeURIComponent(wsId) + "/delete";
@@ -1046,6 +960,8 @@ function executeDeleteWs() {
return r.json();
})
.then(function () {
window.TurnstoneHatch.setBusy(dlg, false);
cancelDeleteWs();
// Update local state directly — don't call closeWorkstream which
// would send a redundant POST to /close for an already-deleted ws.
delete workstreams[wsId];
@@ -1058,6 +974,7 @@ function executeDeleteWs() {
showToast("Workstream deleted", "success");
})
.catch(function (err) {
window.TurnstoneHatch.setBusy(dlg, false);
showToast(err.message || "Failed to delete workstream", "error");
});
}
@@ -1556,17 +1473,19 @@ function connectGlobalSSE() {
}
// ===========================================================================
// 12. MCP consent badge (standalone settings-gear pending-consent indicator)
// 12. MCP consent badge (standalone pending-consent indicator)
//
// The tool-output / media / MCP-error / verdict renderers that used to live in
// this section moved to shared_static/interactive.js with the Pane. What
// stays here is the standalone consent-badge subsystem: the gear badge lives
// in this shell's header, so the pane only notifies it (host.onConsentDetected
// -> _onConsentDetected) and the dashboard hydrates it via loadPendingConsents.
// stays here is the standalone consent-badge subsystem: it owns the pending set
// and drives the rail's Manage > Connections row badge (via the TS_SHELL bridge
// — `setRowBadge`). An interactive pane only NOTIFIES it (the shared host
// bridges `onConsentDetected` to the TS_APP seam below); `loadPendingConsents`
// hydrates it on boot. The settings-gear it used to hang on is retired.
// ===========================================================================
// Module-level set of servers with an unresolved consent prompt; drives the
// gear-icon badge so the user has a stable signal that re-consent is pending
// Manage-row badge so the user has a stable signal that re-consent is pending
// after the inline card scrolls out of view.
const _pendingConsentServers = new Set();
@@ -1611,32 +1530,26 @@ function loadPendingConsents() {
});
}
// The Manage tab the pending-consent badge rides on. The standalone's Manage IA
// (TS_ADMIN.ia, below) is a single Extensions > Connections tab where MCP server
// connections live; the badge surfaces there (and, when that group is collapsed,
// on its head — the rail handles that). The retired settings-gear it used to
// hang on is gone with the L-shell renovation.
const _CONSENT_BADGE_TAB = "connections";
function _refreshConsentBadge() {
const btn = document.getElementById("settings-btn");
if (!btn) return;
let existing = btn.querySelector(".settings-consent-badge");
const n = _pendingConsentServers.size;
// Keep the visible badge and the accessible name in lockstep so screen-
// reader users get the same pending-consent signal that sighted users
// get from the red dot. The badge itself stays aria-hidden because the
// count is already reflected in the button's aria-label/title.
if (n === 0) {
if (existing) existing.remove();
btn.setAttribute("aria-label", "Settings");
btn.setAttribute("title", "Settings");
return;
}
if (!existing) {
existing = document.createElement("span");
existing.className = "settings-consent-badge";
existing.setAttribute("aria-hidden", "true");
btn.appendChild(existing);
}
existing.textContent = String(n);
// Drive the rail's generic Manage-row badge through the shell bridge (classic
// app.js can't import the ESM rail module). The chip's own ⚠ glyph + count
// carry the signal; `label` keeps the accessible name in lockstep. A no-op
// before the rail mounts — `loadPendingConsents` re-drives it after boot.
const shell = window.TS_SHELL;
if (!shell || typeof shell.setRowBadge !== "function") return;
const label =
"Settings (" + n + " MCP consent" + (n === 1 ? "" : "s") + " pending)";
btn.setAttribute("aria-label", label);
btn.setAttribute("title", label);
n === 0
? ""
: n + " MCP server" + (n === 1 ? "" : "s") + " awaiting consent";
shell.setRowBadge(_CONSENT_BADGE_TAB, n, label);
}
/**
@@ -1652,141 +1565,6 @@ function _refreshConsentBadge() {
* Render the action card for an MCP error envelope. Mirrors the
* media-embed pattern: visible card on top, collapsible raw JSON below.
*/
// ---------------------------------------------------------------------------
// HLS lazy-loader (follows the mermaid.js lazy-load pattern in
// /shared/renderer.js)
// ---------------------------------------------------------------------------
let _hlsState = "idle";
let _hlsQueue = [];
function _loadHls(callback) {
if (_hlsState === "ready") {
callback();
return;
}
_hlsQueue.push(callback);
if (_hlsState === "loading") return;
_hlsState = "loading";
const script = document.createElement("script");
script.src = "/shared/hls-1.6.16/hls.min.js";
script.onload = function () {
_hlsState = "ready";
const q = _hlsQueue;
_hlsQueue = [];
for (let i = 0; i < q.length; i++) q[i]();
};
script.onerror = function () {
_hlsState = "idle";
const q = _hlsQueue;
_hlsQueue = [];
// Fall through — _activatePlayer will use stream_url since Hls is undefined
for (let i = 0; i < q.length; i++) q[i]();
};
document.head.appendChild(script);
}
function _isHlsUrl(url) {
return typeof url === "string" && /\.m3u8(\?|$)/i.test(url);
}
// ---------------------------------------------------------------------------
// Click-to-play delegated handler (follows img-placeholder pattern)
// ---------------------------------------------------------------------------
function _activatePlayer(btn) {
const url = btn.dataset.streamUrl;
const hlsUrl = btn.dataset.hlsUrl;
const isAudio = btn.dataset.audioOnly === "true";
const directStream = btn.dataset.directStream === "true";
const player = document.createElement(isAudio ? "audio" : "video");
player.controls = true;
player.autoplay = true;
player.className = "media-player";
// Prefer direct stream when the source supports it; fall back to HLS
// only when transcoding is needed.
if (directStream && url) {
player.src = url;
} else if (
hlsUrl &&
!isAudio &&
typeof Hls !== "undefined" &&
Hls.isSupported()
) {
const hls = new Hls();
hls.loadSource(hlsUrl);
hls.attachMedia(player);
} else if (
hlsUrl &&
!isAudio &&
player.canPlayType("application/vnd.apple.mpegurl")
) {
player.src = hlsUrl;
} else {
player.src = url;
}
player.addEventListener("error", function () {
const card = player.closest(".media-embed");
const titleEl = card ? card.querySelector(".media-card-title") : null;
const label = titleEl ? ": " + titleEl.textContent : "";
const err = document.createElement("div");
err.className = "media-player-error";
err.setAttribute("role", "alert");
err.textContent = "Failed to load stream" + label;
const retry = document.createElement("button");
retry.className = "media-play-btn";
retry.type = "button";
retry.dataset.streamUrl = url;
retry.dataset.hlsUrl = hlsUrl || "";
retry.dataset.audioOnly = String(isAudio);
retry.dataset.directStream = String(directStream);
retry.setAttribute("aria-label", "Retry" + label);
retry.appendChild(document.createTextNode("\u25b6 Retry"));
const container = document.createElement("div");
container.appendChild(err);
container.appendChild(retry);
player.replaceWith(container);
});
btn.replaceWith(player);
}
document.addEventListener("click", function (e) {
const btn = e.target.closest(".media-play-btn");
if (!btn) return;
e.preventDefault();
btn.disabled = true;
const labelEl = btn.querySelector("span:last-child");
if (labelEl) {
labelEl.textContent = "Loading\u2026";
} else {
btn.textContent = "\u25b6 Loading\u2026";
}
const hlsUrl = btn.dataset.hlsUrl;
const isAudio = btn.dataset.audioOnly === "true";
// If HLS URL present and not audio, ensure hls.js is loaded first
if (hlsUrl && !isAudio && _isHlsUrl(hlsUrl)) {
_loadHls(function () {
_activatePlayer(btn);
});
} else {
_activatePlayer(btn);
}
});
document.addEventListener("keydown", function (e) {
if (e.key !== "Enter") return;
const btn = e.target.closest(".media-play-btn");
if (!btn) return;
btn.click();
});
function _announce(text) {
const el = document.getElementById("toast");
if (!el) return;
@@ -1890,9 +1668,6 @@ function _announce(text) {
// ===========================================================================
let _pendingRevokeServer = null;
let _settingsTrap = null;
let _revokeMcpTrap = null;
let _settingsReturnFocus = null;
function openSettingsPanel() {
// MCP connections render in the Admin pane's Connections panel (#view-admin),
@@ -1903,29 +1678,12 @@ function openSettingsPanel() {
}
function closeSettingsPanel() {
// If the nested revoke confirmation is still up, tear it down first
// — otherwise hiding the parent panel would leave an orphan modal
// overlay floating with its own keydown trap still attached. The
// Escape-key path inside the parent's keydown trap defers to the
// inner trap; this branch is the close-button path that doesn't go
// through that trap.
const inner = document.getElementById("revoke-mcp-overlay");
if (inner && inner.style.display !== "none") {
// If the nested revoke confirmation is still up, close it first so
// hiding the parent panel doesn't strand an open dialog.
const inner = document.getElementById("revoke-mcp-dialog");
if (inner && inner.open) {
cancelRevokeMcp();
}
if (_settingsTrap) {
document.removeEventListener("keydown", _settingsTrap);
_settingsTrap = null;
}
if (
_settingsReturnFocus &&
typeof _settingsReturnFocus.focus === "function"
) {
try {
_settingsReturnFocus.focus();
} catch (_) {}
}
_settingsReturnFocus = null;
}
// ---------------------------------------------------------------------------
@@ -2042,54 +1800,27 @@ function promptRevokeMcp(server) {
if (!server) return;
_pendingRevokeServer = server;
const msg = document.getElementById("revoke-mcp-message");
const overlay = document.getElementById("revoke-mcp-overlay");
if (msg) {
msg.textContent =
"Disconnect " +
"Revoke the connection to " +
server +
"? Tools that need this server will require re-consent.";
}
if (overlay) overlay.style.display = "flex";
if (_revokeMcpTrap) document.removeEventListener("keydown", _revokeMcpTrap);
_revokeMcpTrap = function (e) {
if (e.key === "Escape") {
e.preventDefault();
cancelRevokeMcp();
return;
}
if (e.key === "Tab") {
const box = document.getElementById("revoke-mcp-box");
if (!box) return;
const focusable = box.querySelectorAll("button");
if (!focusable.length) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
};
document.addEventListener("keydown", _revokeMcpTrap);
const cancelBtn = overlay
? overlay.querySelector("button:not(.danger)")
: null;
if (cancelBtn) cancelBtn.focus();
document.getElementById("revoke-mcp-confirm").onclick = confirmRevokeMcp;
// Cancel carries the autofocus (the console confirm-dialog rule).
window.TurnstoneHatch.openDialog(
document.getElementById("revoke-mcp-dialog"),
{
onClose: function () {
_pendingRevokeServer = null;
},
},
);
}
function cancelRevokeMcp() {
_pendingRevokeServer = null;
const overlay = document.getElementById("revoke-mcp-overlay");
if (overlay) overlay.style.display = "none";
if (_revokeMcpTrap) {
document.removeEventListener("keydown", _revokeMcpTrap);
_revokeMcpTrap = null;
}
const d = document.getElementById("revoke-mcp-dialog");
if (d && d.open) d.close();
}
function confirmRevokeMcp() {
@@ -2098,16 +1829,20 @@ function confirmRevokeMcp() {
cancelRevokeMcp();
return;
}
const dlg = document.getElementById("revoke-mcp-dialog");
window.TurnstoneHatch.setBusy(dlg, true);
authFetch("/v1/api/mcp/oauth/connections/" + encodeURIComponent(server), {
method: "DELETE",
})
.then(function (r) {
if (!r.ok) throw new Error("HTTP " + r.status);
window.TurnstoneHatch.setBusy(dlg, false);
cancelRevokeMcp();
showToast("Disconnected " + server);
showToast("Revoked connection to " + server);
loadMcpConnections();
})
.catch(function (err) {
window.TurnstoneHatch.setBusy(dlg, false);
cancelRevokeMcp();
showToast("Failed to revoke: " + err.message);
});
@@ -2131,18 +1866,9 @@ function _formatRelativeTimestamp(iso) {
}
document.addEventListener("keydown", function (e) {
// Defer to modal's own keydown handler when any modal is open
const modalIds = [
"new-ws-overlay",
"edit-title-overlay",
"delete-ws-overlay",
"ws-delete-overlay",
"revoke-mcp-overlay",
];
for (let mi = 0; mi < modalIds.length; mi++) {
const modal = document.getElementById(modalIds[mi]);
if (modal && modal.style.display !== "none") return;
}
// Defer while a document-modal hatch dialog is open — native dialogs own
// their Escape, and global shortcuts must not fire under the top layer.
if (document.querySelector("dialog:modal")) return;
if (e.key === "Escape" && dashboardVisible) {
e.preventDefault();
hideDashboard();
@@ -2500,6 +2226,11 @@ window.TS_APP.onRender = function (cb) {
};
window.TS_APP.bucketByParent = bucketByParent;
window.TS_APP.boot = boot;
// Live MCP-consent notifications from an interactive pane (the shared pane host
// bridges its `onConsentDetected` here when this seam exists; the console leaves
// it undefined, so the pane no-ops there). Adds the server to the pending set
// and re-paints the Manage-row badge.
window.TS_APP.onConsentDetected = _onConsentDetected;
// --- Manage seam: one Connections tab (MCP server connections) -------------
const _CONN_IA = [
+141 -99
View File
@@ -22,6 +22,7 @@
<link rel="stylesheet" href="/static/style.css" />
<link rel="stylesheet" href="/shared/shell.css" />
<link rel="stylesheet" href="/shared/interactive.css" />
<link rel="stylesheet" href="/shared/hatch.css" />
<link rel="stylesheet" href="/shared/katex-0.17.0/katex.min.css" />
</head>
<body>
@@ -307,49 +308,63 @@
</div>
</div>
<!-- New workstream modal -->
<div
id="new-ws-overlay"
style="display: none"
role="dialog"
aria-modal="true"
<!-- New workstream dialog (document-modal) — also the fork launcher:
showNewWsModal(forkFromWsId) retitles the head and hides the
skill / attach rows (a fork inherits its parent's history). -->
<dialog
class="hatch hatch--dialog hatch--md"
id="new-ws-dialog"
data-kind="create"
aria-labelledby="new-ws-title"
>
<div id="new-ws-box">
<h3 id="new-ws-title">New Workstream</h3>
<div id="new-ws-error" role="alert" aria-live="assertive"></div>
<header class="sh-head">
<span class="sh-led" aria-hidden="true"></span>
<h2 class="sh-title" id="new-ws-title">New workstream</h2>
<span class="sh-tag" aria-hidden="true" id="new-ws-tag">WS-NEW</span>
<button class="sh-x" data-close aria-label="Close"></button>
</header>
<div class="sh-body">
<div
id="new-ws-error"
class="sh-alert"
role="alert"
aria-live="assertive"
aria-atomic="true"
></div>
<label for="new-ws-name"
>Name <span class="nws-hint">optional</span></label
>Name <span class="label-hint">optional</span></label
>
<input
id="new-ws-name"
type="text"
placeholder="Auto-generated if empty"
autocomplete="off"
autofocus
/>
<label for="new-ws-model"
>Model <span class="nws-hint">optional</span></label
>Model <span class="label-hint">optional</span></label
>
<select id="new-ws-model">
<option value="">Default model</option>
</select>
<label for="new-ws-judge-model"
>Judge Model <span class="nws-hint">optional</span></label
>Judge model <span class="label-hint">optional</span></label
>
<select id="new-ws-judge-model">
<option value="">Default (agent model)</option>
</select>
<label for="new-ws-skill"
>Skill <span class="nws-hint">optional</span></label
>Skill <span class="label-hint">optional</span></label
>
<select id="new-ws-skill">
<option value="">Use defaults</option>
</select>
<label for="new-ws-initial-message"
>First message <span class="nws-hint">optional</span></label
>First message <span class="label-hint">optional</span></label
>
<textarea
id="new-ws-initial-message"
class="sh-mono"
rows="3"
placeholder="Sent as the first turn after the workstream is created"
></textarea>
@@ -366,7 +381,7 @@
id="new-ws-attach-input"
type="file"
multiple
style="display: none"
hidden
accept="image/png,image/jpeg,image/gif,image/webp,text/*,.md,.txt,.json,.yaml,.yml,.toml,.xml,.html,.css,.js,.jsx,.ts,.tsx,.py,.go,.rs,.java,.c,.cpp,.h,.hpp,.sh,.sql,.ini,.conf"
/>
<div
@@ -375,113 +390,139 @@
aria-label="Pending attachments"
></div>
</div>
<div id="new-ws-buttons">
<button id="new-ws-cancel" type="button">Cancel</button>
<button id="new-ws-submit" type="button">Create</button>
</div>
</div>
</div>
<footer class="sh-foot">
<div class="sh-foot-meta"></div>
<button class="sh-btn" data-close>Cancel</button>
<button id="new-ws-submit" class="sh-btn sh-btn--primary">
Create
</button>
</footer>
</dialog>
<!-- Edit title modal -->
<div
id="edit-title-overlay"
style="display: none"
role="dialog"
aria-modal="true"
<!-- Rename workstream dialog (document-modal) — a styled prompt(): one
field, Enter submits, no designation plate. -->
<dialog
class="hatch hatch--dialog"
id="edit-title-dialog"
data-kind="edit"
aria-labelledby="edit-title-heading"
>
<div id="edit-title-box">
<h3 id="edit-title-heading">Edit Title</h3>
<header class="sh-head">
<span class="sh-led" aria-hidden="true"></span>
<h2 class="sh-title" id="edit-title-heading">Rename workstream</h2>
<button class="sh-x" data-close aria-label="Close"></button>
</header>
<div class="sh-body">
<input
id="edit-title-input"
type="text"
maxlength="80"
placeholder="Enter title..."
onkeydown="
if (event.key === 'Enter') submitEditTitle();
if (event.key === 'Escape') cancelEditTitle();
"
aria-label="Workstream title"
/>
<div id="edit-title-buttons">
<button type="button" onclick="cancelEditTitle()">Cancel</button>
<button type="button" onclick="submitEditTitle()">Save</button>
</div>
</div>
</div>
<footer class="sh-foot">
<div class="sh-foot-meta"></div>
<button class="sh-btn" data-close>Cancel</button>
<button id="edit-title-save" class="sh-btn sh-btn--primary">
Save
</button>
</footer>
</dialog>
<!-- Delete workstream confirmation modal -->
<div
id="delete-ws-overlay"
style="display: none"
role="dialog"
aria-modal="true"
<!-- Delete workstream confirmation (document-modal) -->
<dialog
class="hatch hatch--dialog"
id="delete-ws-dialog"
data-kind="danger"
role="alertdialog"
aria-labelledby="delete-ws-heading"
aria-describedby="delete-ws-message"
>
<div id="delete-ws-box">
<h3 id="delete-ws-heading">Delete Workstream</h3>
<p id="delete-ws-message"></p>
<div id="delete-ws-buttons">
<button type="button" onclick="cancelDeleteWs()">Cancel</button>
<button type="button" class="danger" onclick="executeDeleteWs()">
Delete
</button>
</div>
<header class="sh-head">
<span class="sh-led" aria-hidden="true"></span>
<h2 class="sh-title" id="delete-ws-heading">Delete workstream</h2>
<button class="sh-x" data-close aria-label="Close"></button>
</header>
<div class="sh-body">
<p class="sh-prose" id="delete-ws-message"></p>
</div>
</div>
<footer class="sh-foot">
<div class="sh-foot-meta"></div>
<button class="sh-btn" data-close autofocus>Cancel</button>
<button id="delete-ws-confirm" class="sh-btn sh-btn--danger">
Delete
</button>
</footer>
</dialog>
<!-- Revoke MCP connection confirmation modal -->
<div
id="revoke-mcp-overlay"
style="display: none"
role="dialog"
aria-modal="true"
<!-- Revoke MCP connection confirmation (document-modal) -->
<dialog
class="hatch hatch--dialog"
id="revoke-mcp-dialog"
data-kind="danger"
role="alertdialog"
aria-labelledby="revoke-mcp-heading"
aria-describedby="revoke-mcp-message"
>
<div id="revoke-mcp-box">
<h3 id="revoke-mcp-heading">Revoke connection?</h3>
<p id="revoke-mcp-message"></p>
<div id="revoke-mcp-buttons">
<button type="button" onclick="cancelRevokeMcp()">Cancel</button>
<button type="button" class="danger" onclick="confirmRevokeMcp()">
Revoke
</button>
</div>
<header class="sh-head">
<span class="sh-led" aria-hidden="true"></span>
<h2 class="sh-title" id="revoke-mcp-heading">Revoke connection</h2>
<button class="sh-x" data-close aria-label="Close"></button>
</header>
<div class="sh-body">
<p class="sh-prose" id="revoke-mcp-message"></p>
</div>
</div>
<footer class="sh-foot">
<div class="sh-foot-meta"></div>
<button class="sh-btn" data-close autofocus>Cancel</button>
<button id="revoke-mcp-confirm" class="sh-btn sh-btn--danger">
Revoke
</button>
</footer>
</dialog>
<!-- Delete workstreams confirmation modal (batch) -->
<div
id="ws-delete-overlay"
class="ws-delete-modal-overlay"
style="display: none"
role="dialog"
aria-modal="true"
<!-- Delete workstreams confirmation (batch, document-modal) — the list,
foot meta and action label are populated by the shared cards.js
controller (idPrefix "ws-delete"). -->
<dialog
class="hatch hatch--dialog hatch--md"
id="ws-delete-dialog"
data-kind="danger"
role="alertdialog"
aria-labelledby="ws-delete-title"
aria-describedby="ws-delete-count"
>
<div id="ws-delete-box" class="ws-delete-modal-box">
<h3 id="ws-delete-title">Delete Workstreams</h3>
<div id="ws-delete-error" role="alert" aria-live="assertive"></div>
<p id="ws-delete-count"></p>
<div id="ws-delete-list" class="ws-delete-modal-list"></div>
<div id="ws-delete-buttons" class="ws-delete-modal-buttons">
<button
id="ws-delete-cancel-btn"
type="button"
onclick="cancelWsDelete()"
>
Cancel
</button>
<button
id="ws-delete-confirm-btn"
class="ws-delete-confirm"
type="button"
onclick="confirmWsDelete()"
>
Delete
</button>
</div>
<header class="sh-head">
<span class="sh-led" aria-hidden="true"></span>
<h2 class="sh-title" id="ws-delete-title">Delete workstreams</h2>
<button class="sh-x" data-close aria-label="Close"></button>
</header>
<div class="sh-body">
<div
id="ws-delete-error"
class="sh-alert"
role="alert"
aria-live="assertive"
aria-atomic="true"
></div>
<p
class="sh-prose"
id="ws-delete-count"
aria-live="polite"
aria-atomic="true"
></p>
<div id="ws-delete-list"></div>
</div>
</div>
<footer class="sh-foot">
<div class="sh-foot-meta" id="ws-delete-meta"></div>
<button class="sh-btn" data-close autofocus>Cancel</button>
<button id="ws-delete-confirm-btn" class="sh-btn sh-btn--danger">
Delete
</button>
</footer>
</dialog>
<div id="toast" role="status" aria-live="polite"></div>
<script>
@@ -575,6 +616,7 @@
<script type="module" src="/shared/composer_queue.js"></script>
<script type="module" src="/shared/status_bar.js"></script>
<script type="module" src="/shared/auth.js"></script>
<script type="module" src="/shared/hatch.js"></script>
<script type="module" src="/shared/kb.js"></script>
<script src="/shared/katex-0.17.0/katex.min.js"></script>
<script src="/shared/hljs-11.11.1/highlight.min.js"></script>
+3 -308
View File
@@ -56,75 +56,6 @@
/* .card-wsid moved to /shared/cards.css (with vertical-align added so it
aligns with .card-meta). Single source for both surfaces. */
/* Edit title & delete modals */
#edit-title-overlay,
#delete-ws-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
}
#edit-title-box,
#delete-ws-box {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 24px;
max-width: 400px;
width: 90%;
}
#edit-title-box h3,
#delete-ws-box h3 {
margin: 0 0 12px;
font-size: 16px;
color: var(--fg-bright);
}
#edit-title-input {
width: 100%;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg);
color: var(--fg-bright);
font-size: 14px;
font-family: inherit;
margin-bottom: 16px;
box-sizing: border-box;
}
#edit-title-input:focus {
outline: 1px solid var(--accent);
border-color: var(--accent);
}
#edit-title-buttons,
#delete-ws-buttons {
display: flex;
gap: 8px;
justify-content: flex-end;
}
#edit-title-buttons button,
#delete-ws-buttons button {
padding: 8px 20px;
border-radius: var(--radius);
font-size: 13px;
font-weight: 500;
cursor: pointer;
border: 1px solid var(--border);
background: transparent;
color: var(--fg-bright);
}
#delete-ws-buttons button.danger {
background: var(--red);
color: #fff;
border-color: var(--red);
}
#delete-ws-message {
font-size: 14px;
color: var(--fg-bright);
margin: 0 0 16px;
}
/* Pane the interactive pane root (interactive.js). The split-pane chrome
that used to ride it (.split-handle, .pane-header, .pane-action-btn,
.focused) was retired with the step-6 fork collapse; the L-shell's tab bar
@@ -776,7 +707,8 @@ body {
outline-offset: 1px;
}
/* New-workstream modal: attachment row + chips */
/* New-workstream dialog: attachment row + chips (the dialog chrome itself
is /shared/hatch.css; only the attach affordance is page-specific) */
#new-ws-attach-row {
display: flex;
flex-direction: column;
@@ -851,19 +783,6 @@ body {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
#new-ws-initial-message {
width: 100%;
font-family: var(--font-mono);
font-size: 13px;
resize: vertical;
min-height: 60px;
background: var(--bg);
color: var(--fg);
border: 1px solid var(--border-strong);
border-radius: 4px;
padding: 6px 8px;
box-sizing: border-box;
}
/* Drag-and-drop visual state on the pane */
.pane.pane-drop-target {
@@ -1615,164 +1534,6 @@ audio.media-player {
}
}
/* ==========================================================================
New workstream modal
========================================================================== */
#new-ws-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
z-index: 200;
display: flex;
justify-content: center;
align-items: center;
}
#new-ws-box {
background: var(--bg-surface);
border: 1px solid var(--border-strong);
border-radius: var(--radius);
padding: 28px 32px 24px;
width: 380px;
max-width: 90vw;
max-height: 85vh;
overflow-y: auto;
box-shadow:
0 24px 48px -12px rgba(0, 0, 0, 0.5),
0 0 80px -20px var(--accent-dim);
position: relative;
}
#new-ws-box::before {
content: "";
position: absolute;
top: -1px;
left: 20%;
right: 20%;
height: 2px;
background: linear-gradient(90deg, transparent, var(--accent), transparent);
border-radius: 1px;
}
#new-ws-box h3 {
font-family: var(--font-ui);
color: var(--accent);
font-size: 14px;
font-weight: 600;
letter-spacing: 0.04em;
margin: 0 0 16px;
}
#new-ws-box label {
display: block;
font-family: var(--font-ui);
font-size: 10px;
font-weight: 600;
color: var(--fg-dim);
text-transform: uppercase;
letter-spacing: 0.08em;
margin-top: 14px;
margin-bottom: 5px;
}
#new-ws-box label:first-of-type {
margin-top: 0;
}
.nws-hint {
font-weight: 400;
text-transform: none;
letter-spacing: 0;
opacity: 0.6;
}
#new-ws-box input[type="text"],
#new-ws-box select {
display: block;
width: 100%;
padding: 9px 12px;
background: var(--bg);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
color: var(--fg);
font: inherit;
font-size: 13px;
box-sizing: border-box;
transition:
border-color 0.15s,
box-shadow 0.15s;
}
#new-ws-box input[type="text"]:focus,
#new-ws-box select:focus {
border-color: var(--accent);
outline: none;
box-shadow: 0 0 0 3px var(--accent-dim);
}
#new-ws-box input[type="text"]::placeholder {
color: var(--fg-dim);
}
#new-ws-box select {
cursor: pointer;
appearance: none;
-webkit-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%238a93ad' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
padding-right: 32px;
}
#new-ws-error {
color: var(--red);
font-size: 12px;
margin-bottom: 8px;
display: none;
}
#new-ws-buttons {
display: flex;
gap: 8px;
margin-top: 20px;
justify-content: flex-end;
}
#new-ws-cancel {
padding: 9px 20px;
background: var(--bg-highlight);
color: var(--fg);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
font-family: var(--font-ui);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition:
background 0.15s,
border-color 0.15s;
}
#new-ws-cancel:hover {
background: var(--bg-elevated);
}
#new-ws-cancel:focus-visible {
outline: 2px solid var(--fg-bright);
outline-offset: 2px;
}
#new-ws-submit {
padding: 9px 20px;
background: var(--accent);
color: var(--bg);
border: 1px solid var(--accent);
border-radius: var(--radius-sm);
font-family: var(--font-ui);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: filter 0.15s;
}
#new-ws-submit:hover {
filter: brightness(1.1);
}
#new-ws-submit:disabled {
opacity: 0.4;
cursor: not-allowed;
filter: none;
}
#new-ws-submit:focus-visible {
outline: 2px solid var(--fg-bright);
outline-offset: 2px;
}
/* ==========================================================================
Reduced motion page-specific
========================================================================== */
@@ -1795,11 +1556,7 @@ audio.media-player {
#mcp-status,
.msg.assistant tbody tr,
.msg.assistant .img-placeholder,
.media-play-btn,
#new-ws-cancel,
#new-ws-submit,
#new-ws-box input,
#new-ws-box select {
.media-play-btn {
transition: none;
}
}
@@ -2065,65 +1822,3 @@ audio.media-player {
.settings-revoke-btn:hover {
background: rgba(255, 0, 0, 0.05);
}
.settings-consent-badge {
display: inline-block;
margin-left: 4px;
background: var(--red);
color: var(--bg);
border-radius: 8px;
font-size: 10px;
padding: 1px 5px;
vertical-align: top;
font-weight: 600;
line-height: 1.4;
font-family: var(--font-ui);
}
/* Revoke confirmation modal — uses the existing delete-ws pattern */
#revoke-mcp-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1001;
}
#revoke-mcp-box {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 20px;
width: min(420px, 90vw);
font-family: var(--font-ui);
}
#revoke-mcp-box h3 {
margin: 0 0 12px 0;
font-size: 16px;
color: var(--fg);
}
#revoke-mcp-message {
font-size: 13px;
color: var(--fg-dim);
margin: 0 0 16px 0;
}
#revoke-mcp-buttons {
display: flex;
justify-content: flex-end;
gap: 8px;
}
#revoke-mcp-buttons button {
padding: 6px 14px;
border-radius: 4px;
border: 1px solid var(--border);
background: transparent;
color: var(--fg);
font-family: var(--font-ui);
font-size: 13px;
cursor: pointer;
}
#revoke-mcp-buttons button.danger {
background: var(--red);
color: var(--bg);
border-color: var(--red);
}
Generated
+2 -2
View File
@@ -2324,7 +2324,7 @@ wheels = [
[[package]]
name = "turnstone"
version = "1.6.0rc1"
version = "1.6.2"
source = { editable = "." }
dependencies = [
{ name = "alembic" },
@@ -2385,7 +2385,7 @@ requires-dist = [
{ name = "httpx", specifier = ">=0.28" },
{ name = "httpx-sse", specifier = ">=0.4" },
{ name = "lacme", specifier = ">=1.0.5" },
{ name = "mcp", specifier = ">=1.27" },
{ name = "mcp", specifier = ">=1.27,<2" },
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.14" },
{ name = "openai", specifier = ">=2.37" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2" },