mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-14 07:52:25 -06:00
Compare commits
59 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 23007e3ac5 | |||
| f44886a55f | |||
| 3803feb008 | |||
| d0e9aa3dbe | |||
| 81a3eaecce | |||
| 9b75a12848 | |||
| 695a335744 | |||
| 9e0c86e78a | |||
| c15dcee1f5 | |||
| b3c3acc5d1 | |||
| 6bb47cad2d | |||
| 160062235a | |||
| b991dc2e83 | |||
| 9102f858a4 | |||
| 8b5af71603 | |||
| f40404bfb9 | |||
| 472f12e14a | |||
| 1dda974b3e | |||
| e2693764bf | |||
| c592486046 | |||
| c1a0417f10 | |||
| 360ed48f62 | |||
| 69fb8c3140 | |||
| d896108ae0 | |||
| ecef600c0b | |||
| 14f159ccdf | |||
| ddcf337883 | |||
| e54bdd1bc6 | |||
| e6cf2c9346 | |||
| f2d76bbec6 | |||
| 4adf223dd9 | |||
| 0e0532eee5 | |||
| eba53edd36 | |||
| 425c38dc0e | |||
| 5b0255f468 | |||
| f086e38e37 | |||
| 8f415f9c68 | |||
| b24b029c4d | |||
| c75afd704b | |||
| fb282fab73 | |||
| d0cede5e13 | |||
| effdb8f365 | |||
| 461d01f72a | |||
| 9fefe830d1 | |||
| a3ff07a86d | |||
| 319b73d046 | |||
| e1b99bcec6 | |||
| b1c526a170 | |||
| 221e804ef3 | |||
| 539ed3ccbf | |||
| 1d2046b5bb | |||
| 3bfb40f60f | |||
| 2320c6d13c | |||
| f6f7d1fff7 | |||
| 3fc65577f5 | |||
| 4b1536be2c | |||
| 91c4afb2d0 | |||
| 114ada791b | |||
| ab6d95da24 |
+200
-58
@@ -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
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -3,7 +3,7 @@
|
||||
[](https://github.com/turnstonelabs/turnstone/actions/workflows/ci.yml)
|
||||
[](https://pypi.org/project/turnstone/)
|
||||
[](https://pypi.org/project/turnstone/)
|
||||
[](LICENSE)
|
||||
[](LICENSE)
|
||||
[](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
@@ -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
@@ -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__":
|
||||
|
||||
@@ -237,14 +237,21 @@ Key properties:
|
||||
- **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 to reach a real
|
||||
- **Progress throttling** — the poll loop runs every 500 ms but the
|
||||
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.
|
||||
- **Unresolvable ids** — ws_ids are validated up front (exactly
|
||||
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).
|
||||
|
||||
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
|
||||
children take, whereas each `inspect_workstream` poll costs a full
|
||||
|
||||
+25
-11
@@ -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
@@ -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
@@ -108,7 +108,7 @@ pgbouncer:
|
||||
maxClientConn: 5000
|
||||
maxDbConnections: 80
|
||||
```
|
||||
:
|
||||
|
||||
---
|
||||
|
||||
## Configuration reference
|
||||
|
||||
+18
-17
@@ -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
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
+4
-3
@@ -4,10 +4,11 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "turnstone"
|
||||
version = "1.6.0a12"
|
||||
version = "1.6.0"
|
||||
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"]
|
||||
@@ -23,7 +24,7 @@ classifiers = [
|
||||
]
|
||||
dependencies = [
|
||||
"openai>=2.37",
|
||||
"anthropic>=0.39",
|
||||
"anthropic>=0.108", # claude-fable-5 support; hard runtime floor is 0.105 (mid-conversation system blocks)
|
||||
"httpx>=0.28",
|
||||
"mcp>=1.27",
|
||||
"starlette>=1.0.1", # PYSEC-2026-161: host-header path-injection in URL reconstruction (auth-bypass on apps comparing reconstructed URL paths)
|
||||
|
||||
Executable
+503
@@ -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()
|
||||
Generated
+1
-1
@@ -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"
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"sdk",
|
||||
"client"
|
||||
],
|
||||
"license": "BUSL-1.1",
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"typescript": "^6.0.0",
|
||||
"vitest": "^4.1"
|
||||
|
||||
+30
-13
@@ -628,7 +628,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 +638,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 +674,9 @@ 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."""
|
||||
css = _STYLE_CSS.read_text(encoding="utf-8")
|
||||
for selector in [
|
||||
".mcp-error-card",
|
||||
@@ -681,7 +685,6 @@ def test_phase8_css_classes_present_in_stylesheet() -> None:
|
||||
".mcp-scope-pill",
|
||||
".settings-revoke-btn",
|
||||
".settings-consent-badge",
|
||||
"#revoke-mcp-overlay",
|
||||
]:
|
||||
assert selector in css, f"Missing CSS rule for {selector}"
|
||||
|
||||
@@ -795,19 +798,33 @@ def _slice_function_body(body: str, fn_name: str) -> str | None:
|
||||
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
# Bundles that completed the var → const/let sweep. Add a new JS file
|
||||
# here only after it has itself been swept — the var-free + const-reassign
|
||||
# guards below will otherwise fail loudly on any pre-sweep `var` it
|
||||
# contains. coordinator.js is intentionally excluded (already modern;
|
||||
# 3 surviving `var` are by design per the sweep briefing).
|
||||
# CLASSIC bundles that completed the var → const/let sweep. Add a new JS
|
||||
# file here only after it has itself been swept — the var-free +
|
||||
# const-reassign guards below will otherwise fail loudly on any pre-sweep
|
||||
# `var` it contains. coordinator.js is intentionally excluded (already
|
||||
# modern; 3 surviving `var` are by design per the sweep briefing). The
|
||||
# shared_static files that used to sit here (auth/kb/utils) are ES modules
|
||||
# now — test_shell_js.py sweeps them with module semantics.
|
||||
_SWEPT_BUNDLES = [
|
||||
_REPO_ROOT / "turnstone/ui/static/app.js",
|
||||
_REPO_ROOT / "turnstone/console/static/admin.js",
|
||||
_REPO_ROOT / "turnstone/console/static/governance.js",
|
||||
_REPO_ROOT / "turnstone/console/static/app.js",
|
||||
]
|
||||
|
||||
# The const-reassign analysis below is pure text — module vs script semantics
|
||||
# is irrelevant — so the var-free ES modules ride the same guard (their parse
|
||||
# + var + sink guards live in test_shell_js.py).
|
||||
_CONST_GUARD_BUNDLES = _SWEPT_BUNDLES + [
|
||||
_REPO_ROOT / "turnstone/shared_static/auth.js",
|
||||
_REPO_ROOT / "turnstone/shared_static/kb.js",
|
||||
_REPO_ROOT / "turnstone/shared_static/utils.js",
|
||||
_REPO_ROOT / "turnstone/shared_static/toast.js",
|
||||
_REPO_ROOT / "turnstone/shared_static/shell.js",
|
||||
_REPO_ROOT / "turnstone/shared_static/pane.js",
|
||||
_REPO_ROOT / "turnstone/shared_static/rail.js",
|
||||
_REPO_ROOT / "turnstone/shared_static/interactive.js",
|
||||
_REPO_ROOT / "turnstone/shared_static/conversation.js",
|
||||
]
|
||||
|
||||
|
||||
@@ -1046,7 +1063,7 @@ def _enclosing_block(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bundle", _SWEPT_BUNDLES, ids=lambda p: p.name)
|
||||
@pytest.mark.parametrize("bundle", _CONST_GUARD_BUNDLES, ids=lambda p: p.name)
|
||||
def test_swept_bundle_has_no_const_reassign(bundle: Path) -> None:
|
||||
"""For each ``const X = …`` declaration, fail if X is reassigned
|
||||
*within the same block scope* (``X = …``, ``X +=``, ``X++``, ``++X``,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -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)"
|
||||
)
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -42,18 +42,20 @@ def test_interactive_is_esm_imported_by_the_shell() -> None:
|
||||
|
||||
|
||||
def test_pane_constructor_takes_transport_and_host_seam() -> None:
|
||||
"""The constructor grew the ``(wsId, opts)`` seam: a transport ``base`` (the
|
||||
node-proxy prefix), an ``embedded`` flag (drop the standalone split-pane
|
||||
chrome), and a ``host`` adapter for the few things only the surrounding
|
||||
shell knows."""
|
||||
"""The constructor takes the ``(wsId, opts)`` seam: a transport ``base``
|
||||
(the node-proxy prefix) and a ``host`` adapter for the few things only the
|
||||
surrounding shell knows. The old ``embedded`` flag is gone — every pane is
|
||||
L-shell-hosted since the step-6 fork collapse."""
|
||||
body = _INTERACTIVE.read_text(encoding="utf-8")
|
||||
assert "constructor(wsId, opts) {" in body
|
||||
for field in (
|
||||
"this._base = opts.base",
|
||||
"this._embedded = !!opts.embedded",
|
||||
"this._host = opts.host || INTERACTIVE_DEFAULT_HOST",
|
||||
):
|
||||
assert field in body, f"missing constructor seam: {field!r}"
|
||||
assert "opts.embedded" not in body, (
|
||||
"the embedded flag is retired — every pane is L-shell-hosted."
|
||||
)
|
||||
|
||||
|
||||
def test_transport_urls_are_base_prefixed() -> None:
|
||||
@@ -76,21 +78,33 @@ def test_transport_urls_are_base_prefixed() -> None:
|
||||
assert "new EventSource(evtUrl" in body
|
||||
|
||||
|
||||
def test_embedded_chrome_is_gated() -> None:
|
||||
"""The standalone split-pane affordances (focus tracking, context menu,
|
||||
split/close buttons) AND the pane header are gated behind ``!this._embedded``:
|
||||
the console (embedded) pane has NO header — name / persona / state live in
|
||||
the tab + rail, and the conversation reclaims the full pane height."""
|
||||
def test_split_pane_chrome_is_retired() -> None:
|
||||
"""The standalone split-pane chrome is GONE, not gated: no pane header
|
||||
(name / persona / state live in the tab + rail; the conversation owns the
|
||||
full pane height), no focus tracking, no split/close buttons. The dead
|
||||
``!this._embedded`` branches referenced shell globals (setFocusedPane,
|
||||
splitPane, splitRoot…) that no longer exist anywhere — reaching them was a
|
||||
guaranteed ReferenceError, so their removal is a bugfix too."""
|
||||
body = _INTERACTIVE.read_text(encoding="utf-8")
|
||||
assert "if (!this._embedded) {" in body, "standalone chrome must be gated"
|
||||
assert 'this.el.classList.add("pane--embedded")' in body
|
||||
# The embedded pane builds NO header — the persona tag is gone (the rail's
|
||||
# INT/COORD vocabulary shows it instead).
|
||||
assert "_embedded" not in body, "the embedded gate is retired (always-on)"
|
||||
assert 'className = "pane pane--embedded"' in body, (
|
||||
"the pane root must carry pane--embedded unconditionally — "
|
||||
"interactive.css scopes the slim-chrome layout to it"
|
||||
)
|
||||
for gone in (
|
||||
"setFocusedPane",
|
||||
"showPaneContextMenu",
|
||||
"splitPane(",
|
||||
"splitRoot",
|
||||
"this.headerEl",
|
||||
'"pane-header"',
|
||||
'"pane-action-btn"',
|
||||
"updateWsName",
|
||||
):
|
||||
assert gone not in body, f"retired split-pane symbol {gone!r} resurfaced"
|
||||
# The persona tag stays gone (the rail's INT/COORD vocabulary shows it).
|
||||
assert '"pane-persona-tag"' not in body
|
||||
assert '"INTERACTIVE"' not in body
|
||||
# The header (workstream name + split/close actions) builds for standalone
|
||||
# only — gated, not unconditional.
|
||||
assert 'this.headerEl = document.createElement("div")' in body
|
||||
|
||||
|
||||
def test_factory_returns_lifecycle_over_node_proxy() -> None:
|
||||
@@ -114,13 +128,16 @@ def test_host_seam_routes_shell_couplings() -> None:
|
||||
badge reference survives in the module."""
|
||||
body = _INTERACTIVE.read_text(encoding="utf-8")
|
||||
for call in (
|
||||
"this._host.getWsName(",
|
||||
"this._host.isFocused(this)",
|
||||
"this._host.onStreamError(this)",
|
||||
"this._host.warningTarget(this)",
|
||||
"this._host.onConsentDetected(",
|
||||
):
|
||||
assert call in body, f"missing host seam call {call!r}"
|
||||
assert "getWsName" not in body, (
|
||||
"getWsName left the host seam with the pane header — the tab + rail "
|
||||
"own the workstream name now."
|
||||
)
|
||||
code = _strip_comments(body)
|
||||
# The classic split-pane shell globals must not leak into the module as
|
||||
# bare code references (URL path strings excepted, handled above).
|
||||
@@ -165,3 +182,30 @@ def test_approval_keyboard_shortcuts_wired() -> None:
|
||||
"the feedback field uses the converged .conv-feedback, not the retired "
|
||||
".ts-approval-feedback"
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
CLOSED recovery beats → give up: stream closed, status bar terminal,
|
||||
``opts.onDead()`` fired once. A successful stream open resets the counter
|
||||
(the new host.onStreamOpen seam). ``isDead()`` / ``markDead()`` / ``base``
|
||||
are the shell's revive surface; a dead controller also ignores the login
|
||||
re-arm (recovery may need a DIFFERENT node — the shell's revive owns it)."""
|
||||
body = _INTERACTIVE.read_text(encoding="utf-8")
|
||||
# The give-up ladder.
|
||||
assert "let dead = false;" in body and "let failCount = 0;" in body
|
||||
assert "const giveUp = function () {" in body
|
||||
assert "failCount += 1;" in body and "if (failCount >= 3) giveUp();" in body
|
||||
assert 'pane._sbTokens.textContent = "Disconnected"' in body, (
|
||||
"the terminal state must be worded distinctly from the transient Reconnecting…"
|
||||
)
|
||||
assert "opts.onDead" in body, "the shell must hear about the give-up"
|
||||
# The reset seam: Pane.connectSSE onopen → host.onStreamOpen → failCount = 0.
|
||||
assert "this._host.onStreamOpen(this)" in body
|
||||
assert "onStreamOpen() {}" in body, "the default host must carry the no-op"
|
||||
# The shell-facing surface.
|
||||
assert "isDead()" in body and "markDead: giveUp," in body
|
||||
assert "base: base," in body, "the controller must expose its transport base"
|
||||
# Dead controllers don't reconnect on re-auth.
|
||||
assert "if (connected && !dead) pane._loadHistoryThenConnect(wsId);" in body
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1407,6 +1407,35 @@ class TestAnthropicHelpers:
|
||||
assert caps.token_param == "max_tokens"
|
||||
assert caps.thinking_mode == "adaptive"
|
||||
|
||||
def test_capabilities_fable_5(self) -> None:
|
||||
from turnstone.core.providers._anthropic import AnthropicProvider
|
||||
|
||||
provider = AnthropicProvider()
|
||||
caps = provider.get_capabilities("claude-fable-5")
|
||||
assert caps.context_window == 1000000
|
||||
assert caps.max_output_tokens == 128000
|
||||
assert caps.thinking_mode == "adaptive"
|
||||
assert caps.supports_effort is True
|
||||
assert "xhigh" in caps.effort_levels
|
||||
assert "max" in caps.effort_levels
|
||||
assert caps.supports_temperature is False
|
||||
assert caps.thinking_display == "summarized"
|
||||
assert caps.supports_web_search is True
|
||||
assert caps.supports_tool_search is True
|
||||
assert caps.supports_vision is True
|
||||
assert caps.supports_reasoning_replay is True
|
||||
assert caps.supports_mid_conversation_system is True
|
||||
|
||||
def test_capabilities_fable_5_dated(self) -> None:
|
||||
from turnstone.core.providers._anthropic import AnthropicProvider
|
||||
|
||||
provider = AnthropicProvider()
|
||||
caps = provider.get_capabilities("claude-fable-5-20260815")
|
||||
assert caps.context_window == 1000000
|
||||
assert caps.supports_temperature is False
|
||||
assert caps.thinking_display == "summarized"
|
||||
assert caps.supports_mid_conversation_system is True
|
||||
|
||||
def test_capabilities_opus_4_8(self) -> None:
|
||||
from turnstone.core.providers._anthropic import AnthropicProvider
|
||||
|
||||
|
||||
+35
-11
@@ -11,6 +11,14 @@ Each test invokes ``node -e`` with a small wrapper that loads
|
||||
the rendered HTML for a sample input. The assertions check the
|
||||
resulting markup contains the expected ``<span class="katex">…</span>``
|
||||
placeholder and not the raw delimiter.
|
||||
|
||||
Both files are ES modules now; ``_demodulize`` strips the module syntax
|
||||
so the script-semantics harness keeps working. That is DELIBERATE, not
|
||||
a shortcut: the mermaid harness pokes renderer-internal state
|
||||
(``_mermaidState``) that script evaluation exposes as a context global
|
||||
but a real module would encapsulate. Module semantics themselves are
|
||||
covered elsewhere (``test_shell_js`` parses every shared module with
|
||||
``node --check`` as ``.mjs``); these tests pin renderer BEHAVIOR.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -36,9 +44,24 @@ def _has_node() -> bool:
|
||||
pytestmark = pytest.mark.skipif(not _has_node(), reason="node not available")
|
||||
|
||||
|
||||
def _demodulize(path: Path) -> str:
|
||||
"""Strip ES-module syntax so ``vm.runInThisContext`` (script semantics)
|
||||
can evaluate the file: imports drop (the harness loads the whole
|
||||
dependency set into one shared context, so cross-file bindings resolve
|
||||
as context globals, exactly like the pre-module classic scripts), and
|
||||
``export`` keywords peel off their declarations."""
|
||||
src = path.read_text(encoding="utf-8")
|
||||
src = re.sub(r"^import\s+\{[\s\S]*?\}\s+from\s+\"[^\"]+\";\s*$", "", src, flags=re.M)
|
||||
src = re.sub(r"^import\s+[^;\n]+;\s*$", "", src, flags=re.M)
|
||||
src = re.sub(
|
||||
r"^export\s+(?=(?:async\s+)?(?:function|const|let|var|class)\b)", "", src, flags=re.M
|
||||
)
|
||||
src = re.sub(r"^export\s*\{[^}]*\};\s*$", "", src, flags=re.M)
|
||||
return src
|
||||
|
||||
|
||||
_HARNESS_TEMPLATE = """
|
||||
const vm = require('vm');
|
||||
const fs = require('fs');
|
||||
global.document = {
|
||||
createElement: () => {
|
||||
let t = '';
|
||||
@@ -60,8 +83,8 @@ global.katex = {
|
||||
']</span>',
|
||||
};
|
||||
global.window = global;
|
||||
vm.runInThisContext(fs.readFileSync(%(utils)s, 'utf8'));
|
||||
vm.runInThisContext(fs.readFileSync(%(renderer)s, 'utf8'));
|
||||
vm.runInThisContext(%(utils_src)s);
|
||||
vm.runInThisContext(%(renderer_src)s);
|
||||
const input = %(input)s;
|
||||
process.stdout.write(renderMarkdown(input));
|
||||
"""
|
||||
@@ -70,8 +93,8 @@ process.stdout.write(renderMarkdown(input));
|
||||
def _render(markdown: str) -> str:
|
||||
"""Render ``markdown`` through renderer.js + return the HTML."""
|
||||
harness = _HARNESS_TEMPLATE % {
|
||||
"utils": json.dumps(str(_UTILS_JS)),
|
||||
"renderer": json.dumps(str(_RENDERER_JS)),
|
||||
"utils_src": json.dumps(_demodulize(_UTILS_JS)),
|
||||
"renderer_src": json.dumps(_demodulize(_RENDERER_JS)),
|
||||
"input": json.dumps(markdown),
|
||||
}
|
||||
result = subprocess.run(
|
||||
@@ -263,7 +286,6 @@ def test_dollar_signs_never_render_as_math_across_paragraphs() -> None:
|
||||
|
||||
_MERMAID_HARNESS_TEMPLATE = """
|
||||
const vm = require('vm');
|
||||
const fs = require('fs');
|
||||
|
||||
// Minimal DOM fake — enough surface for postRenderMermaid + the
|
||||
// mermaid render path. Each created element tracks its attributes,
|
||||
@@ -431,12 +453,14 @@ global.hljs = {
|
||||
},
|
||||
};
|
||||
|
||||
vm.runInThisContext(fs.readFileSync(%(utils)s, 'utf8'));
|
||||
vm.runInThisContext(fs.readFileSync(%(renderer)s, 'utf8'));
|
||||
vm.runInThisContext(%(utils_src)s);
|
||||
vm.runInThisContext(%(renderer_src)s);
|
||||
|
||||
// Mermaid is normally lazy-loaded via _loadMermaid which fetches a
|
||||
// script tag. Force-mark it ready so postRenderMermaid invokes the
|
||||
// render path synchronously without trying to inject a script.
|
||||
// render path synchronously without trying to inject a script. (This
|
||||
// poke is WHY the harness script-evaluates the demodulized source: a
|
||||
// real module would encapsulate _mermaidState.)
|
||||
_mermaidState = 'ready';
|
||||
|
||||
%(scenario)s
|
||||
@@ -446,8 +470,8 @@ _mermaidState = 'ready';
|
||||
def _run_mermaid_scenario(scenario_js: str) -> dict[str, Any]:
|
||||
"""Run a JS snippet against the mermaid-aware harness, return JSON output."""
|
||||
harness = _MERMAID_HARNESS_TEMPLATE % {
|
||||
"utils": json.dumps(str(_UTILS_JS)),
|
||||
"renderer": json.dumps(str(_RENDERER_JS)),
|
||||
"utils_src": json.dumps(_demodulize(_UTILS_JS)),
|
||||
"renderer_src": json.dumps(_demodulize(_RENDERER_JS)),
|
||||
"scenario": scenario_js,
|
||||
}
|
||||
result = subprocess.run(
|
||||
|
||||
@@ -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"])
|
||||
|
||||
+103
-5
@@ -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()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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."""
|
||||
|
||||
+313
-14
@@ -1,12 +1,12 @@
|
||||
"""Static smoke guards for the L-shell ES-module bundles.
|
||||
"""Static smoke guards for the shared_static ES-module bundles.
|
||||
|
||||
``shell.js`` + ``pane.js`` are the first ES-module citizens in
|
||||
``shared_static`` (the rest are classic IIFE scripts loaded via ``<script
|
||||
src>``). This mirrors ``test_app_js.py``'s posture — Python-side string /
|
||||
parse assertions that catch the silent one-line regression — adapted for
|
||||
module semantics: ``node --check`` only parses ``import`` / ``export`` when
|
||||
the file carries an ``.mjs`` extension, so the parse guard copies to a temp
|
||||
``.mjs`` first.
|
||||
The whole shared substrate is ES modules now (utils/toast/auth/composer/…
|
||||
followed shell.js + pane.js; only theme.js — FOUC-critical — and the vendored
|
||||
libs stay classic). This mirrors ``test_app_js.py``'s posture — Python-side
|
||||
string / parse assertions that catch the silent one-line regression — adapted
|
||||
for module semantics: ``node --check`` only parses ``import`` / ``export``
|
||||
when the file carries an ``.mjs`` extension, so the parse guard copies to a
|
||||
temp ``.mjs`` first.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -29,7 +29,45 @@ _CONSOLE_APP = _ROOT / "turnstone/console/static/app.js"
|
||||
_CONSOLE_ADMIN = _ROOT / "turnstone/console/static/admin.js"
|
||||
|
||||
_RAIL_JS = _SHARED / "rail.js"
|
||||
_ESM_BUNDLES = [_SHELL_JS, _PANE_JS, _RAIL_JS]
|
||||
|
||||
# Every shared ES module — parse-guarded with module semantics. (auth/kb/
|
||||
# utils moved here from test_app_js's classic sweep when they were converted.)
|
||||
_ESM_BUNDLES = [
|
||||
_SHELL_JS,
|
||||
_PANE_JS,
|
||||
_RAIL_JS,
|
||||
_SHARED / "utils.js",
|
||||
_SHARED / "toast.js",
|
||||
_SHARED / "kb.js",
|
||||
_SHARED / "cards.js",
|
||||
_SHARED / "auth.js",
|
||||
_SHARED / "renderer.js",
|
||||
_SHARED / "status_bar.js",
|
||||
_SHARED / "composer.js",
|
||||
_SHARED / "composer_attachments.js",
|
||||
_SHARED / "composer_queue.js",
|
||||
_SHARED / "interactive.js",
|
||||
_SHARED / "conversation.js",
|
||||
]
|
||||
|
||||
# Sink scan: everything except renderer.js — the one sanctioned HTML-string
|
||||
# producer (its output is consumed via setSafeHtml; see utils.js docstring).
|
||||
_ESM_SINK_BUNDLES = [b for b in _ESM_BUNDLES if b.name != "renderer.js"]
|
||||
|
||||
# Style ratchet: var-free modules only. The lifted legacy bundles (cards/
|
||||
# renderer/status_bar/composer*) keep their pre-module `var` style — converting
|
||||
# them was a loader change, not a rewrite; don't grow NEW var use elsewhere.
|
||||
_ESM_NO_VAR_BUNDLES = [
|
||||
_SHELL_JS,
|
||||
_PANE_JS,
|
||||
_RAIL_JS,
|
||||
_SHARED / "utils.js",
|
||||
_SHARED / "toast.js",
|
||||
_SHARED / "kb.js",
|
||||
_SHARED / "auth.js",
|
||||
_SHARED / "interactive.js",
|
||||
_SHARED / "conversation.js",
|
||||
]
|
||||
|
||||
# The same unsafe DOM-write / dynamic-code sink set that ``test_app_js.py``
|
||||
# pins for the classic bundles — kept local so the two test files stay
|
||||
@@ -65,7 +103,7 @@ def test_esm_bundle_parses(bundle: Path) -> None:
|
||||
assert proc.returncode == 0, f"node --check failed for {bundle.name}:\n{proc.stderr}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bundle", _ESM_BUNDLES, ids=lambda p: p.name)
|
||||
@pytest.mark.parametrize("bundle", _ESM_SINK_BUNDLES, ids=lambda p: p.name)
|
||||
def test_esm_bundle_no_unsafe_sink(bundle: Path) -> None:
|
||||
"""The shell builds DOM with createElement / textContent / append — never
|
||||
an HTML-string sink. Keep it that way (XSS-free by construction once the
|
||||
@@ -75,7 +113,7 @@ def test_esm_bundle_no_unsafe_sink(bundle: Path) -> None:
|
||||
assert not offenders, f"{bundle.name}: unsafe DOM/code sink at line(s) {offenders[:10]}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bundle", _ESM_BUNDLES, ids=lambda p: p.name)
|
||||
@pytest.mark.parametrize("bundle", _ESM_NO_VAR_BUNDLES, ids=lambda p: p.name)
|
||||
def test_esm_bundle_has_no_var_decl(bundle: Path) -> None:
|
||||
"""Match the modern-keyword posture of the swept classic bundles."""
|
||||
body = bundle.read_text(encoding="utf-8")
|
||||
@@ -453,7 +491,9 @@ def test_step7_tab_menu_wired_per_persona() -> None:
|
||||
header's removed Export + end (5e.2e) return here as Export + Close workstream
|
||||
(its controller's closeSession). The three-verb close (Close pane = pm.close
|
||||
!= Close workstream != Delete) is the spine; the standalone interactive verbs
|
||||
are feature-detected globals, so the console degrades to a reduced menu."""
|
||||
prefer the feature-detected globals (which also manage its local roster), and
|
||||
a deployment without them (the console) falls back to the base-aware lane
|
||||
(see test_tab_menu_base_aware_verb_lane)."""
|
||||
shell = _SHELL_JS.read_text(encoding="utf-8")
|
||||
assert "function convTabMenu(" in shell, "the shared tab-menu builder must exist"
|
||||
assert shell.count("pane.tabMenu =") >= 3, (
|
||||
@@ -471,10 +511,77 @@ def test_step7_tab_menu_wired_per_persona() -> None:
|
||||
assert "exportWorkstreamDownload" in shell, "Export conversation must wire the shared util"
|
||||
# Deployment-aware: the standalone interactive verbs are feature-detected globals.
|
||||
assert 'typeof window.closeWorkstream === "function"' in shell, (
|
||||
"the interactive Close workstream is a standalone-only global (feature-detected)"
|
||||
"the interactive Close workstream prefers the standalone global (feature-detected)"
|
||||
)
|
||||
assert "refreshWorkstreamTitle" in shell and "confirmDeleteWorkstream" in shell, (
|
||||
"the interactive title/delete verbs are feature-detected standalone globals"
|
||||
"the interactive title/delete verbs prefer the standalone globals"
|
||||
)
|
||||
|
||||
|
||||
def test_tab_menu_base_aware_verb_lane() -> None:
|
||||
"""Lifecycle round 2: a proxied interactive pane's tab menu must act on the
|
||||
pane's OWN transport base, not the console origin — the globals lane only
|
||||
exists on the standalone. convTabMenu therefore takes a `base` getter and
|
||||
falls back to POSTing the verb at {base}/v1/api/workstreams/{ws}/{verb}; a
|
||||
node-verb is OMITTED while no base is resolvable (never aimed at the wrong
|
||||
origin), and Export forwards the base to the shared util (a proxied export
|
||||
must come from the node that owns the conversation)."""
|
||||
shell = _SHELL_JS.read_text(encoding="utf-8")
|
||||
assert "function postWsVerb(" in shell, "the base-aware verb POST helper must exist"
|
||||
assert '"/v1/api/workstreams/" + encodeURIComponent(wsId) + "/" + verb' in shell
|
||||
# The interactive pane supplies its current base: a LIVE controller's
|
||||
# (exact), else the persisted node hint, else the live Tier-1 node, else null
|
||||
# (a DEAD controller's stale base is special-cased — see the test below).
|
||||
assert "const menuBase = ()" in shell, "the interactive pane must expose a base getter"
|
||||
assert "pane._ctl && pane._ctl.base != null" in shell, (
|
||||
"a built controller's base is authoritative for the menu verbs"
|
||||
)
|
||||
# Fallback verbs exist for the console: refresh-title / title / close / delete.
|
||||
for verb in ('"refresh-title"', '"title"', '"close"', '"delete"'):
|
||||
assert (
|
||||
f"postWsVerb(base, wsId, {verb}" in shell
|
||||
or f"postWsVerb(closeBase, id, {verb}" in shell
|
||||
), f"the {verb} verb must have a base-aware fallback"
|
||||
# Export rides the base too (3-arg form), and node-verbs are null-gated.
|
||||
assert "exportWorkstreamDownload(wsId, null, base)" in shell
|
||||
assert "base != null" in shell, "node-verbs must be omitted while the base is unresolved"
|
||||
# Destructive fallbacks confirm first (window.confirm is the house precedent).
|
||||
assert shell.count("window.confirm(") >= 2, (
|
||||
"the close + delete fallbacks must confirm before acting"
|
||||
)
|
||||
# No leading separator when the verb section is empty.
|
||||
assert "if (items.length) items.push({ separator: true })" in shell
|
||||
util = (_SHARED / "utils.js").read_text(encoding="utf-8")
|
||||
assert "function exportWorkstreamDownload(wsId, btn, base)" in util, (
|
||||
"the shared export util must accept the transport base"
|
||||
)
|
||||
assert '(base || "") +' in util, "the export URL must be base-prefixed"
|
||||
|
||||
|
||||
def test_tab_menu_dead_controller_prefers_live_node() -> None:
|
||||
"""Lifecycle round 2 follow-up: a DEAD controller's base is stale — its node
|
||||
may have lost or RE-HOMED the ws — so the tab-menu base getter must not keep
|
||||
aiming verbs at it. Otherwise the close/delete 404-as-success lanes would
|
||||
silently drop a tab whose session is alive on the node it re-homed to. When
|
||||
dead, ``menuBase`` mirrors the revive path (the live Tier-1 node leads); the
|
||||
stale controller base is only the gone-cluster-wide fallback.
|
||||
"""
|
||||
shell = _SHELL_JS.read_text(encoding="utf-8")
|
||||
body = shell[shell.index("const menuBase = ()") :]
|
||||
body = body[: body.index("};") + 2]
|
||||
# The dead-controller guard must come FIRST — before the live-base return —
|
||||
# so a stale base can never authorise the 404-as-success drop.
|
||||
assert "pane._ctl.isDead && pane._ctl.isDead()" in body, (
|
||||
"menuBase must special-case a dead controller"
|
||||
)
|
||||
assert body.index("isDead()") < body.index("pane._ctl.base != null"), (
|
||||
"the dead-controller guard must precede the authoritative live-base return"
|
||||
)
|
||||
# When dead the live Tier-1 node leads (mirrors beginConnect's hint chain),
|
||||
# falling back to the controller's own (stale) base only when no live node.
|
||||
assert 'live ? "/node/" + encodeURIComponent(live) : pane._ctl.base' in body, (
|
||||
"a dead pane aims at the live node, falling back to the stale base only "
|
||||
"when the ws is gone cluster-wide"
|
||||
)
|
||||
|
||||
|
||||
@@ -559,3 +666,195 @@ def test_step7_auth_gated_open_pane() -> None:
|
||||
assert "canOpen:" in shell and "onDeny:" in shell, (
|
||||
"the coordinator registerType must supply the auth gate"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Workstream-lifecycle round 2: dead-session revive + explicit-reopen seam.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_pane_manager_reopen_seam() -> None:
|
||||
"""openPane() on an ALREADY-OPEN pane fires `pane.onReopen(extra)` — the
|
||||
explicit-intent signal (saved-list resume, rail row, child link) that
|
||||
activate() cannot carry: hooks no-op on the already-active pane, and
|
||||
onActivate also fires on plain tab switches. Fired AFTER activate so the
|
||||
pane is visible when it reacts. getPane lets the shell reach a pane for
|
||||
cross-cutting lifecycle signals."""
|
||||
pane = _PANE_JS.read_text(encoding="utf-8")
|
||||
assert "onReopen(extra) {}" in pane, "ShellPane must document the onReopen hook"
|
||||
assert "const existed = !!pane" in pane, "openPane must remember create-vs-focus"
|
||||
assert "pane.onReopen(extra)" in pane, "openPane must fire onReopen on existing panes"
|
||||
# Ordering: the reopen signal comes after activation.
|
||||
assert pane.index("this.activate(paneId)") < pane.index("pane.onReopen(extra)")
|
||||
assert "getPane(type, id)" in pane, "PaneManager must expose getPane for the shell"
|
||||
|
||||
|
||||
def test_interactive_pane_dead_session_revive() -> None:
|
||||
"""The reported round-2 bug: an interactive session whose stream died
|
||||
(closed / evicted / node restarted) could never reconnect while its tab
|
||||
existed — openPane focused the dead pane, onActivate's connect() is one-shot,
|
||||
and the controller's recovery loop re-dialed the SAME node forever. The fix:
|
||||
the shell paints a click-to-reconnect banner when the controller reports
|
||||
dead, and an explicit reopen (onReopen) revives — tear down the dead
|
||||
controller, re-resolve the node (POST /open), rebuild."""
|
||||
shell = _SHELL_JS.read_text(encoding="utf-8")
|
||||
assert "const showDeadBanner = ()" in shell, "the dead banner painter must exist"
|
||||
assert "pane-dead-banner" in shell, "the banner carries its own style hook"
|
||||
css = _SHELL_CSS.read_text(encoding="utf-8")
|
||||
assert ".pane-dead-banner" in css and ".pane-dead-banner:focus-visible" in css, (
|
||||
"the banner is a real <button>; shell.css must reset its native chrome "
|
||||
"and give it a keyboard focus treatment"
|
||||
)
|
||||
assert "Session disconnected" in shell, "the banner states the terminal condition"
|
||||
assert "const revive = (freshNodeId)" in shell, "the revive path must exist"
|
||||
assert "onDead: showDeadBanner" in shell, (
|
||||
"the controller's terminal give-up must surface the banner"
|
||||
)
|
||||
# Revive is full teardown + re-resolve: unsubscribe login, destroy, rebuild.
|
||||
ridx = shell.index("const revive =")
|
||||
rbody = shell[ridx : ridx + 900]
|
||||
assert "TS_LOGIN.unsubscribe" in rbody and "destroy()" in rbody
|
||||
assert "beginConnect(true)" in rbody, "revive must force the resolve path"
|
||||
# onActivate shows the banner for a dead controller instead of connect();
|
||||
# onReopen revives (the resume-with-a-pre-existing-tab path).
|
||||
assert "this._ctl.isDead && this._ctl.isDead()" in shell
|
||||
assert "revive(reExtra && reExtra.nodeId)" in shell, (
|
||||
"onReopen must revive with the caller's fresh node hint"
|
||||
)
|
||||
# The standalone revive path must (re)open the local session — /events 404s
|
||||
# on an unloaded ws; only the forceResolve lane POSTs /open.
|
||||
assert "function ensureInteractiveNode(caps, wsId, hint, openFirst)" in shell
|
||||
eidx = shell.index("function ensureInteractiveNode(")
|
||||
ebody = shell[eidx : eidx + 700]
|
||||
assert '"/open"' in ebody and '{ method: "POST" }' in ebody.replace("\n", " ").replace(
|
||||
" ", " "
|
||||
).replace(" ", " "), "standalone openFirst must POST /open"
|
||||
# Revive must skip BOTH fast paths (a stale Tier-1 row must not bypass the
|
||||
# /open), while the live node — when present — stays the resolve HINT so an
|
||||
# origin-first /open reuses a genuinely-live session instead of loading a
|
||||
# duplicate copy on the old meta node.
|
||||
assert "if (!forceResolve && (liveNode || !caps.cluster))" in shell, (
|
||||
"beginConnect's fast paths must both yield to the forced resolve"
|
||||
)
|
||||
assert "liveNode || (pane.meta && pane.meta.nodeId)" in shell, (
|
||||
"the live Tier-1 node must lead the resolve-hint chain"
|
||||
)
|
||||
|
||||
|
||||
def test_shell_marks_pane_dead_on_ws_closed() -> None:
|
||||
"""Tier-1 ws_closed → the open pane stops reconnect-polling a session that
|
||||
is GONE and shows the reconnect affordance immediately (the console keeps
|
||||
the tab — unlike the standalone, which closes the pane outright)."""
|
||||
shell = _SHELL_JS.read_text(encoding="utf-8")
|
||||
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, (
|
||||
"the seam must be exported on TS_SHELL for the console's Tier-1 handler"
|
||||
)
|
||||
app = _CONSOLE_APP.read_text(encoding="utf-8")
|
||||
closed = app.index('=== "ws_closed"')
|
||||
block = app[closed : closed + 1200]
|
||||
assert "notifySessionClosed" in block, (
|
||||
"the console ws_closed handler must notify the shell's open pane"
|
||||
)
|
||||
|
||||
|
||||
def test_coordinator_pane_reconnects_on_reopen() -> None:
|
||||
"""The coordinator variant of resume-with-a-pre-existing-tab: the saved-list
|
||||
resume POSTs /open BEFORE openPane, so the dead pane just needs a fresh
|
||||
stream — onReopen calls the controller's reconnect(), which resets backoff
|
||||
and reconnects only when the source is gone or CLOSED (OPEN is healthy;
|
||||
CONNECTING means native retry / a fresh connect is already in flight)."""
|
||||
shell = _SHELL_JS.read_text(encoding="utf-8")
|
||||
assert "this._ctl.reconnect()" in shell, (
|
||||
"the coordinator pane's onReopen must drive the controller's reconnect"
|
||||
)
|
||||
coord = (_ROOT / "turnstone/console/static/coordinator/coordinator.js").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "function reconnect()" in coord
|
||||
assert "readyState !== EventSource.CLOSED) return" in coord.replace("\n", " "), (
|
||||
"reconnect must only act on a missing/CLOSED stream"
|
||||
)
|
||||
assert "reconnect: reconnect," in coord, "the factory must return reconnect"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Polish round: rail collapse + mobile drawer + the shared popup-menu helper.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_rail_collapse_glyph_strip() -> None:
|
||||
"""Desktop rail collapse: a persisted preference (localStorage
|
||||
``turnstone_interface.rail``) shrinks the rail to a 52px glyph-only strip —
|
||||
live state glyphs stay the navigation, the cluster pills keep glyph+count
|
||||
(the label rides a hideable span), Manage gets a single gear stand-in that
|
||||
opens the Admin pane, and the toggle mirrors its state through
|
||||
aria-expanded/aria-controls."""
|
||||
shell = _SHELL_JS.read_text(encoding="utf-8")
|
||||
assert 'const RAIL_COLLAPSE_KEY = "turnstone_interface.rail"' in shell, (
|
||||
"the collapse preference must persist under the turnstone_interface key"
|
||||
)
|
||||
assert 'make("button", "rail-collapse")' in shell, "the collapse toggle must exist"
|
||||
assert 'collapseBtn.setAttribute("aria-controls", "shell-rail")' in shell, (
|
||||
"the toggle must reference the rail it controls"
|
||||
)
|
||||
assert 'classList.toggle("rail-collapsed", collapsed)' in shell, (
|
||||
"collapse must be a class flip on .app (CSS owns the layout change)"
|
||||
)
|
||||
rail = _RAIL_JS.read_text(encoding="utf-8")
|
||||
assert '"cpill-label"' in rail, (
|
||||
"cluster pill labels must ride a span so the collapsed strip can hide "
|
||||
"them while keeping glyph + count"
|
||||
)
|
||||
assert "manage-glyph" in rail, (
|
||||
"Manage needs its collapsed-strip gear stand-in (rail.js mountManage)"
|
||||
)
|
||||
css = _SHELL_CSS.read_text(encoding="utf-8")
|
||||
assert "grid-template-columns: 52px 1fr" in css, "the collapsed rail is 52px"
|
||||
assert ".app.rail-collapsed .manage-glyph" in css, (
|
||||
"the gear stand-in must flip on while collapsed"
|
||||
)
|
||||
assert "@media (min-width: 769px)" in css, (
|
||||
"the collapse block must be desktop-scoped (the drawer owns mobile)"
|
||||
)
|
||||
|
||||
|
||||
def test_mobile_drawer_off_canvas() -> None:
|
||||
"""Mobile drawer: below the breakpoint the rail overlays off-canvas at full
|
||||
width. Burger in the tab bar opens it (focus moves into the rail);
|
||||
Escape / scrim tap / any pane activation close it; the closed drawer is
|
||||
visibility:hidden so its buttons leave the Tab order."""
|
||||
shell = _SHELL_JS.read_text(encoding="utf-8")
|
||||
assert 'make("button", "rail-burger"' in shell, "the drawer toggle must exist"
|
||||
assert 'make("div", "rail-scrim")' in shell, "the backdrop scrim must exist"
|
||||
assert 'classList.toggle("rail-open", open)' in shell, (
|
||||
"drawer state must be a class flip on .app"
|
||||
)
|
||||
assert "pm.onActiveChange(() => setDrawer(false))" in shell, (
|
||||
"opening/focusing a pane must close the drawer that did it"
|
||||
)
|
||||
css = _SHELL_CSS.read_text(encoding="utf-8")
|
||||
assert "@media (max-width: 768px)" in css, "the drawer is mobile-scoped"
|
||||
assert "translateX(-100%)" in css, "the closed drawer parks off-canvas"
|
||||
assert "visibility: hidden" in css, (
|
||||
"the closed drawer must leave the Tab order / a11y tree, not merely translate off-screen"
|
||||
)
|
||||
|
||||
|
||||
def test_popup_menu_shared_helper() -> None:
|
||||
"""One popup-menu chrome: pane.js exports openPopupMenu (items,
|
||||
positioning with flip+clamp, dismissal, aria-expanded mirroring, arrow
|
||||
roving) and BOTH consumers ride it — the tab-action dropdown and the
|
||||
shell's footer user menu (which pops up from the viewport-bottom chip)."""
|
||||
pane = _PANE_JS.read_text(encoding="utf-8")
|
||||
assert "export function openPopupMenu(" in pane, "the shared helper must exist"
|
||||
assert pane.count("openPopupMenu(") >= 2, (
|
||||
"the tab-action dropdown must route through the helper"
|
||||
)
|
||||
shell = _SHELL_JS.read_text(encoding="utf-8")
|
||||
assert "openPopupMenu(" in shell, "the user menu must ride the shared helper"
|
||||
assert 'prefer: "up"' in shell, (
|
||||
"the footer chip sits at the viewport bottom — the menu pops upward"
|
||||
)
|
||||
|
||||
@@ -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,3 +1,3 @@
|
||||
"""turnstone - Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."""
|
||||
|
||||
__version__ = "1.6.0a12"
|
||||
__version__ = "1.6.0"
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
@@ -13047,6 +13106,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(
|
||||
|
||||
+905
-701
File diff suppressed because it is too large
Load Diff
+139
-128
@@ -146,6 +146,15 @@ function patchClusterState(data) {
|
||||
if (typeof loadSavedCoordinators === "function") {
|
||||
loadSavedCoordinators();
|
||||
}
|
||||
// An open pane on this session must stop reconnect-polling a stream that
|
||||
// is now gone and show its reconnect affordance instead (the shell owns
|
||||
// the pane lifecycle; this is the Tier-1 → pane seam).
|
||||
if (
|
||||
window.TS_SHELL &&
|
||||
typeof window.TS_SHELL.notifySessionClosed === "function"
|
||||
) {
|
||||
window.TS_SHELL.notifySessionClosed(data.ws_id);
|
||||
}
|
||||
} else if (t === "ws_rename") {
|
||||
Object.keys(clusterState.nodes).forEach(function (nid) {
|
||||
(clusterState.nodes[nid].workstreams || []).forEach(function (ws) {
|
||||
@@ -1714,7 +1723,7 @@ function loadSavedCoordinators() {
|
||||
// Freeze the list while the user is multi-selecting — re-rendering
|
||||
// mid-mode would shuffle the visible page out from under them. The
|
||||
// delete-mode wrapper drains the retry flag on cancel/onClose.
|
||||
if (typeof _coordTable !== "undefined" && _coordTable.controller.inMode()) {
|
||||
if (_coordTable && _coordTable.controller.inMode()) {
|
||||
_savedCoordsRetry = true;
|
||||
return;
|
||||
}
|
||||
@@ -1731,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 (
|
||||
typeof _coordTable !== "undefined" &&
|
||||
_coordTable.controller.inMode()
|
||||
) {
|
||||
if (_coordTable && _coordTable.controller.inMode()) {
|
||||
_savedCoordsRetry = true;
|
||||
return;
|
||||
}
|
||||
@@ -1762,131 +1768,141 @@ function loadSavedCoordinators() {
|
||||
// (/shared/cards.js), with a CHILDREN column instead of MSGS and the
|
||||
// body-keyed (router-proxied) delete. Activation POSTs /open before
|
||||
// navigating so capacity limits surface as a toast, not a broken page.
|
||||
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;
|
||||
let _coordTable = null;
|
||||
|
||||
// Built at boot, not parse: the saved-table substrate (/shared/cards.js) is a
|
||||
// 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;
|
||||
},
|
||||
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(),
|
||||
];
|
||||
const _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
|
||||
// to and forward to the shared controller.
|
||||
@@ -1908,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
|
||||
@@ -2035,6 +2045,7 @@ window.TS_APP.resolveInteractiveNode = function (wsId, hintNodeId) {
|
||||
};
|
||||
window.TS_APP.boot = function () {
|
||||
history.replaceState({ view: "home" }, "");
|
||||
_initSavedCoordTable(); // substrate modules have evaluated by boot time
|
||||
initLogin();
|
||||
// loadOverview fetches the cluster snapshot — both the node list AND
|
||||
// the active-coordinators list come from the same snapshot + SSE patch
|
||||
|
||||
@@ -4682,6 +4682,19 @@ function createCoordinatorPane(root, wsId, opts) {
|
||||
_childObserver.disconnect();
|
||||
}
|
||||
|
||||
// Reconnect a DEAD stream NOW (reset backoff), leaving a live one alone —
|
||||
// OPEN is healthy and CONNECTING means native retry / a fresh connect is
|
||||
// already working the problem. The shell calls this on an explicit re-open
|
||||
// of an already-open pane (saved-list resume POSTs /open first): a
|
||||
// coordinator whose session was closed under the pane sits in the capped
|
||||
// retry loop — this short-circuits straight to a fresh /events against the
|
||||
// reopened session (a stale replay cursor degrades to the server's
|
||||
// fresh-replay path).
|
||||
function reconnect() {
|
||||
if (evtSource && evtSource.readyState !== EventSource.CLOSED) return;
|
||||
onLogin();
|
||||
}
|
||||
|
||||
// Enter-to-send / Shift-Enter newline / IME-safe handling lives in
|
||||
// shared/composer.js; no duplicate listener here.
|
||||
return {
|
||||
@@ -4689,6 +4702,7 @@ function createCoordinatorPane(root, wsId, opts) {
|
||||
connect: init,
|
||||
destroy: destroy,
|
||||
onLogin: onLogin,
|
||||
reconnect: reconnect,
|
||||
closeSession: coordCloseSession,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>coordinator · turnstone</title>
|
||||
<link
|
||||
rel="icon"
|
||||
href="data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2032%2032'%3E%3Crect%20width='32'%20height='32'%20rx='7'%20fill='%230e1013'/%3E%3Cpath%20d='M8%2022a8%208%200%201%201%2016%200'%20fill='none'%20stroke='%23e5a042'%20stroke-width='3'%20stroke-linecap='round'/%3E%3Cpath%20d='M16%2021%20L21%2014'%20stroke='%23e5a042'%20stroke-width='3'%20stroke-linecap='round'/%3E%3C/svg%3E"
|
||||
/>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
@@ -27,25 +31,24 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Shared-static import order matches the console dashboard
|
||||
(console/static/index.html) so global shortcuts (kb.js) and helpers
|
||||
register in the expected sequence. The renderer libs (katex, hljs,
|
||||
renderer.js) match the server UI (ui/static/index.html) so the
|
||||
coordinator page can call renderMarkdown / postRenderMarkdown on
|
||||
streamed assistant content. renderer.js is the single canonical
|
||||
implementation shared across the two chat pages. -->
|
||||
<script src="/shared/utils.js"></script>
|
||||
<script src="/shared/toast.js"></script>
|
||||
<!-- Shared-static set matches the console dashboard
|
||||
(console/static/index.html), two lanes: classic theme.js (FOUC) +
|
||||
vendored katex/hljs; everything else is the deferred shared module
|
||||
substrate (renderer.js is the single canonical markdown implementation
|
||||
shared across the chat pages). The coordinator module below runs after
|
||||
the substrate by document order. -->
|
||||
<script src="/shared/theme.js"></script>
|
||||
<script src="/shared/auth.js"></script>
|
||||
<script src="/shared/kb.js"></script>
|
||||
<script src="/shared/composer.js"></script>
|
||||
<script src="/shared/composer_attachments.js"></script>
|
||||
<script src="/shared/composer_queue.js"></script>
|
||||
<script src="/shared/status_bar.js"></script>
|
||||
<script type="module" src="/shared/utils.js"></script>
|
||||
<script type="module" src="/shared/toast.js"></script>
|
||||
<script type="module" src="/shared/auth.js"></script>
|
||||
<script type="module" src="/shared/kb.js"></script>
|
||||
<script type="module" src="/shared/composer.js"></script>
|
||||
<script type="module" src="/shared/composer_attachments.js"></script>
|
||||
<script type="module" src="/shared/composer_queue.js"></script>
|
||||
<script type="module" src="/shared/status_bar.js"></script>
|
||||
<script src="/shared/katex-0.17.0/katex.min.js"></script>
|
||||
<script src="/shared/hljs-11.11.1/highlight.min.js"></script>
|
||||
<script src="/shared/renderer.js"></script>
|
||||
<script type="module" src="/shared/renderer.js"></script>
|
||||
<script type="module">
|
||||
// Standalone coordinator page = one coordinator pane filling the body.
|
||||
// (The console shell instead creates panes on demand via the same imported
|
||||
|
||||
+1038
-1019
File diff suppressed because it is too large
Load Diff
+2386
-2940
File diff suppressed because it is too large
Load Diff
+104
-445
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -84,6 +84,27 @@ _ANTHROPIC_DEFAULT = ModelCapabilities(
|
||||
)
|
||||
|
||||
_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
|
||||
# explicit thinking={"type": "disabled"} is a 400 on this model; thinking
|
||||
# must be adaptive or the param omitted entirely. The adaptive branch in
|
||||
# _build_thinking_and_kwargs never emits "disabled", so this is safe as
|
||||
# long as thinking_mode stays "adaptive".
|
||||
"claude-fable-5": ModelCapabilities(
|
||||
context_window=1000000,
|
||||
max_output_tokens=128000,
|
||||
token_param="max_tokens",
|
||||
thinking_mode="adaptive",
|
||||
supports_effort=True,
|
||||
effort_levels=("low", "medium", "high", "xhigh", "max"),
|
||||
supports_web_search=True,
|
||||
supports_tool_search=True,
|
||||
supports_vision=True,
|
||||
supports_temperature=False,
|
||||
thinking_display="summarized",
|
||||
supports_reasoning_replay=True,
|
||||
supports_mid_conversation_system=True,
|
||||
),
|
||||
"claude-opus-4-8": ModelCapabilities(
|
||||
context_window=1000000,
|
||||
max_output_tokens=128000,
|
||||
@@ -361,7 +382,8 @@ class AnthropicProvider:
|
||||
(``ChatSession._try_stream`` / ``_utility_completion``) always
|
||||
pass the resolved flag explicitly.
|
||||
|
||||
``supports_mid_conversation_system`` (claude-opus-4-8) makes the
|
||||
``supports_mid_conversation_system`` (claude-opus-4-8,
|
||||
claude-fable-5) makes the
|
||||
system-role handling position-aware: leading system/developer
|
||||
messages (the base prompt) still hoist into the top-level
|
||||
``system`` param, but a system message appearing AFTER a
|
||||
|
||||
@@ -116,7 +116,8 @@ class ModelCapabilities:
|
||||
# invalidating the cached prefix. When False, ``system``-role messages must
|
||||
# be hoisted into the top-level ``system`` param (the universal fallback).
|
||||
# Available on the Claude API only (NOT Bedrock / Vertex / Foundry), on
|
||||
# NextOpus (claude-opus-4-8) only; no beta header required.
|
||||
# claude-opus-4-8 (validated header-less) and claude-fable-5 (same
|
||||
# documented wire surface); no beta header required.
|
||||
supports_mid_conversation_system: bool = False
|
||||
# Phase 3 reranker calibration — populated by calibrate-on-detect; read by
|
||||
# ChatSession._bm25_rerank_threshold. A non-empty rerank_scale is the
|
||||
|
||||
+69
-21
@@ -2734,9 +2734,10 @@ class ChatSession:
|
||||
else None
|
||||
)
|
||||
# Operator-instruction trust anchor — declared only on the fold path.
|
||||
# The native mid-conversation-system path (claude-opus-4-8) delivers
|
||||
# operator turns as real {"role":"system"} messages with no fence, so
|
||||
# no <system-reminder_{nonce}> marker appears and no declaration applies.
|
||||
# The native mid-conversation-system path (claude-opus-4-8,
|
||||
# claude-fable-5) delivers operator turns as real {"role":"system"}
|
||||
# messages with no fence, so no <system-reminder_{nonce}> marker
|
||||
# appears and no declaration applies.
|
||||
if caps is not None and not caps.supports_mid_conversation_system:
|
||||
dev_parts.append("\n\n" + build_operator_instruction_declaration(self._envelope_nonce))
|
||||
# Tool search hint (client-side mode only — native mode needs no hint).
|
||||
@@ -2932,8 +2933,9 @@ class ChatSession:
|
||||
:func:`turnstone.core.lowering.fold_system_turns`: non-native
|
||||
models get each turn wrapped as a nonce-delimited
|
||||
``<system-reminder>`` block on the preceding turn; native
|
||||
mid-conversation-system models (claude-opus-4-8) keep them inline
|
||||
for the Anthropic converter to emit as real ``system`` messages.
|
||||
mid-conversation-system models (claude-opus-4-8, claude-fable-5)
|
||||
keep them inline for the Anthropic converter to emit as real
|
||||
``system`` messages.
|
||||
|
||||
Messages without a foldable system turn pass through unchanged
|
||||
(same object reference) so the common case is allocation-free.
|
||||
@@ -5274,8 +5276,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:
|
||||
@@ -5431,18 +5440,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]
|
||||
@@ -6071,8 +6094,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
|
||||
@@ -9974,7 +10012,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",
|
||||
@@ -9985,7 +10033,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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -3642,6 +3642,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 +3671,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:
|
||||
|
||||
@@ -1676,6 +1676,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 +1736,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 +1756,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.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@@ -3815,6 +3815,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 +3844,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:
|
||||
|
||||
+118
-7
@@ -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:
|
||||
|
||||
@@ -4,9 +4,9 @@ Operator-level context injected mid-session (output-guard findings, user
|
||||
interjections, metacognitive nudges, skill hints) lives in the conversation
|
||||
trajectory as first-class ``{"role": "system", "_source": <kind>, "content":
|
||||
...}`` turns (see :func:`make_system_turn`). At the wire boundary each turn is
|
||||
either kept inline (native mid-conversation system messages — claude-opus-4-8)
|
||||
or folded into the preceding turn as a nonce-delimited ``<system-reminder_
|
||||
{nonce}>`` fence for every other model. The fence mechanism (mint / neutralise
|
||||
either kept inline (native mid-conversation system messages — claude-opus-4-8,
|
||||
claude-fable-5) or folded into the preceding turn as a nonce-delimited
|
||||
``<system-reminder_{nonce}>`` fence for every other model. The fence mechanism (mint / neutralise
|
||||
/ wrap) lives in :mod:`turnstone.core.fence`, shared with the output-guard judge
|
||||
so the two trust boundaries cannot drift; ``lowering.fold_system_turns``
|
||||
applies it.
|
||||
@@ -99,8 +99,8 @@ def render_user_interjection(message: str, priority: str) -> str:
|
||||
# ``{"role": "system", "_source": <kind>, "content": ...}`` messages rather
|
||||
# than spliced into a neighbouring turn's ``content``. At the wire boundary a
|
||||
# system turn is either kept inline (native mid-conversation system messages —
|
||||
# claude-opus-4-8) or folded into the preceding turn as a ``<system-reminder>``
|
||||
# block (every other model). ``_source`` classifies the turn for UI rendering
|
||||
# claude-opus-4-8, claude-fable-5) or folded into the preceding turn as a
|
||||
# ``<system-reminder>`` block (every other model). ``_source`` classifies the turn for UI rendering
|
||||
# and replay; it rides the persisted ``_source`` column and is stripped before
|
||||
# the LLM wire by ``sanitize_messages``. See ``ChatSession`` for the producers
|
||||
# and the fold-or-keep pass.
|
||||
@@ -149,7 +149,8 @@ def make_system_turn(source: str, content: str, **meta: Any) -> dict[str, Any]:
|
||||
structured fields never reach the model.
|
||||
|
||||
``content`` is stored and — on the native mid-conversation-system path
|
||||
(claude-opus-4-8) — sent to the model verbatim, so fence-escaping is NOT
|
||||
(claude-opus-4-8, claude-fable-5) — sent to the model verbatim, so
|
||||
fence-escaping is NOT
|
||||
done here. It belongs to the fallback fold step, which wraps the content
|
||||
in a nonce-delimited ``<system-reminder_{nonce}>`` fence via
|
||||
:func:`turnstone.core.fence.wrap` (applied in
|
||||
|
||||
@@ -89,12 +89,12 @@ def build_operator_instruction_declaration(nonce: str) -> str:
|
||||
|
||||
Declares the per-session *nonce* as the sole trusted ``<system-reminder>``
|
||||
marker so the fold (:func:`turnstone.core.fence.wrap`) can rely on it: the
|
||||
model trusts only ``<system-reminder_<nonce>>`` blocks and treats every
|
||||
model trusts only ``<system-reminder_{nonce}>`` blocks and treats every
|
||||
other ``<system-reminder>``-style marker (e.g. one forged in tool output,
|
||||
files, or web pages) as untrusted data. Emitted only when the model uses
|
||||
the fold path — the native mid-conversation-system path (claude-opus-4-8)
|
||||
delivers operator turns as real ``{"role":"system"}`` messages with no
|
||||
fence, so no marker appears.
|
||||
the fold path — the native mid-conversation-system path (claude-opus-4-8,
|
||||
claude-fable-5) delivers operator turns as real ``{"role":"system"}``
|
||||
messages with no fence, so no marker appears.
|
||||
"""
|
||||
return (
|
||||
"## Operator instructions\n"
|
||||
|
||||
+37
-5
@@ -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__,
|
||||
|
||||
@@ -6,7 +6,18 @@
|
||||
1. Check /v1/api/auth/status — detect if setup is needed
|
||||
2. If setup_required → show first-time setup wizard (create admin user)
|
||||
3. If auth_enabled + has_users → show login (username:password)
|
||||
4. Legacy: token-based login still supported via toggle */
|
||||
4. Legacy: token-based login still supported via toggle
|
||||
|
||||
ES module (imports utils/toast). The window bridge at the bottom keeps
|
||||
the still-classic consumers working — app.js calls initLogin() from its
|
||||
boot path and logout() rides an inline onclick. Parse-time side effects
|
||||
(BroadcastChannel wiring, the whoami refresh schedule, permissionsReady)
|
||||
now run at module eval — after the classic scripts, before the shell
|
||||
module calls TS_APP.boot(); every consumer reads them at boot time or
|
||||
later, so the shift is observable only in ordering, not behaviour. */
|
||||
|
||||
import { escapeHtml, setSafeHtml } from "./utils.js";
|
||||
import { showToast } from "./toast.js";
|
||||
|
||||
const _AUTH_TITLE = window.TURNSTONE_AUTH_TITLE || "turnstone";
|
||||
let _loginTrapHandler = null;
|
||||
@@ -36,7 +47,7 @@ if (_authChannel) {
|
||||
};
|
||||
}
|
||||
|
||||
async function authFetch(url, opts) {
|
||||
export async function authFetch(url, opts) {
|
||||
const maxRetries = 2;
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
const r = await fetch(url, opts);
|
||||
@@ -292,7 +303,7 @@ function _cancelRefreshTimer() {
|
||||
}
|
||||
}
|
||||
|
||||
function initLogin() {
|
||||
export function initLogin() {
|
||||
const overlay = document.createElement("div");
|
||||
overlay.id = "login-overlay";
|
||||
overlay.style.display = "none";
|
||||
@@ -484,7 +495,7 @@ function _showError(msg) {
|
||||
}
|
||||
}
|
||||
|
||||
function showLogin(reason, oidcError) {
|
||||
export function showLogin(reason, oidcError) {
|
||||
const overlay = document.getElementById("login-overlay");
|
||||
if (!overlay) return;
|
||||
overlay.style.display = "flex";
|
||||
@@ -554,7 +565,7 @@ function showLogin(reason, oidcError) {
|
||||
document.addEventListener("keydown", _loginTrapHandler);
|
||||
}
|
||||
|
||||
function hideLogin() {
|
||||
export function hideLogin() {
|
||||
const overlay = document.getElementById("login-overlay");
|
||||
if (overlay) overlay.style.display = "none";
|
||||
document.body.style.overflow = "";
|
||||
@@ -761,7 +772,7 @@ function _onSuccess() {
|
||||
_scheduleRefreshFromWhoami();
|
||||
}
|
||||
|
||||
function logout() {
|
||||
export function logout() {
|
||||
// Set flag + abort in-flight fetches BEFORE the network call so any
|
||||
// concurrent _tryRefresh() / _scheduleRefreshFromWhoami() bails its
|
||||
// post-fetch effects (see _loggedOut handling above). Without this,
|
||||
@@ -792,3 +803,14 @@ function logout() {
|
||||
if (typeof window !== "undefined") {
|
||||
_scheduleRefreshFromWhoami();
|
||||
}
|
||||
|
||||
// --- Legacy window bridge ---------------------------------------------------
|
||||
// Still-classic consumers reach these as globals at event/boot time (after
|
||||
// this deferred module evaluated). New module code imports instead.
|
||||
Object.assign(window, {
|
||||
authFetch,
|
||||
showLogin,
|
||||
hideLogin,
|
||||
logout,
|
||||
initLogin,
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
+116
-109
@@ -9,11 +9,18 @@
|
||||
multi-select delete controller
|
||||
- createSavedCardsController — the delete-mode controller (below)
|
||||
|
||||
ES module (imports utils/toast/auth; window bridge below for the
|
||||
still-classic app.js consumers).
|
||||
|
||||
Built with safe DOM APIs (createElement + textContent), never innerHTML,
|
||||
so user-supplied alias/title/name/skill fields never reach the DOM as
|
||||
HTML. Depends on formatRelativeTime (from /shared/utils.js).
|
||||
*/
|
||||
|
||||
import { formatRelativeTime } from "./utils.js";
|
||||
import { showToast } from "./toast.js";
|
||||
import { authFetch } from "./auth.js";
|
||||
|
||||
/* ==========================================================================
|
||||
Saved-list TABLE primitives — the row builder (renderSessionRow) plus a
|
||||
shared filter / sort / render orchestrator (createSavedTable). Both the
|
||||
@@ -78,7 +85,7 @@ function _nameCell(sess) {
|
||||
cell(sess)->Node|string, sort(sess)->comparable}. The only difference
|
||||
between the two surfaces is count("message_count","MSGS") vs
|
||||
count("child_count","CHILDREN"). */
|
||||
var SavedColumns = {
|
||||
export var SavedColumns = {
|
||||
name: function () {
|
||||
return {
|
||||
key: "name",
|
||||
@@ -173,7 +180,7 @@ var SavedColumns = {
|
||||
list isn't dimmed by base.css's `[data-state="idle"]` rule. The grid
|
||||
template comes from the `--saved-grid` CSS var that createSavedTable sets
|
||||
once per render (not rebuilt per row). */
|
||||
function renderSessionRow(sess, opts) {
|
||||
export function renderSessionRow(sess, opts) {
|
||||
opts = opts || {};
|
||||
var columns = opts.columns || [];
|
||||
var row = document.createElement("div");
|
||||
@@ -234,7 +241,7 @@ function renderSessionRow(sess, opts) {
|
||||
emptyText — empty-state copy
|
||||
delete — {idPrefix, buttonId, buildDeleteRequest, onClose}
|
||||
returns { setItems(items), render(), controller }. */
|
||||
function createSavedTable(opts) {
|
||||
export function createSavedTable(opts) {
|
||||
var state = {
|
||||
items: [],
|
||||
filter: "",
|
||||
@@ -570,21 +577,22 @@ 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 +
|
||||
@@ -601,13 +609,8 @@ function createSavedTable(opts) {
|
||||
closes the post-delete results modal. Typical
|
||||
use: re-fetch the saved list.
|
||||
*/
|
||||
function createSavedCardsController(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);
|
||||
@@ -760,7 +763,7 @@ 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) {
|
||||
@@ -769,6 +772,12 @@ 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) {
|
||||
@@ -778,14 +787,26 @@ 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();
|
||||
@@ -800,92 +821,50 @@ 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) {
|
||||
@@ -911,7 +890,15 @@ 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,
|
||||
@@ -932,6 +919,7 @@ function createSavedCardsController(opts) {
|
||||
});
|
||||
|
||||
Promise.all(promises).then(function () {
|
||||
window.TurnstoneHatch.setBusy(dlg, false);
|
||||
if (listEl) {
|
||||
listEl.replaceChildren();
|
||||
results.forEach(function (r) {
|
||||
@@ -951,27 +939,36 @@ 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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -991,3 +988,13 @@ function createSavedCardsController(opts) {
|
||||
confirm: confirm,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Legacy window bridge ---------------------------------------------------
|
||||
// Still-classic consumers reach these as globals at event/boot time (after
|
||||
// this deferred module evaluated). New module code imports instead.
|
||||
Object.assign(window, {
|
||||
SavedColumns,
|
||||
renderSessionRow,
|
||||
createSavedTable,
|
||||
createSavedCardsController,
|
||||
});
|
||||
|
||||
+742
-742
File diff suppressed because it is too large
Load Diff
@@ -30,278 +30,278 @@
|
||||
* surface a toast if any were dropped.
|
||||
* isEmpty() — true when no chips are pending.
|
||||
*/
|
||||
(function (root) {
|
||||
"use strict";
|
||||
function formatSize(n) {
|
||||
if (n < 1024) return n + " B";
|
||||
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
|
||||
return (n / (1024 * 1024)).toFixed(1) + " MB";
|
||||
}
|
||||
|
||||
function formatSize(n) {
|
||||
if (n < 1024) return n + " B";
|
||||
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
|
||||
return (n / (1024 * 1024)).toFixed(1) + " MB";
|
||||
function _toastError(msg) {
|
||||
if (typeof window.toast !== "undefined" && window.toast.error) {
|
||||
window.toast.error(msg);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} opts
|
||||
* chipsEl: HTMLElement — chips render target (composer.chipsEl).
|
||||
* getWsId: () => string — current workstream id (function so the
|
||||
* interactive pane can swap tabs without re-instantiating).
|
||||
* authFetch: optional override (default window.authFetch).
|
||||
* onError: optional (msg, err) => void — replaces the default toast
|
||||
* for upload failures.
|
||||
*/
|
||||
export function createAttachmentController(opts) {
|
||||
if (!opts || !opts.chipsEl)
|
||||
throw new Error("createAttachmentController: chipsEl required");
|
||||
if (typeof opts.getWsId !== "function")
|
||||
throw new Error("createAttachmentController: getWsId must be a function");
|
||||
var chipsEl = opts.chipsEl;
|
||||
var getWsId = opts.getWsId;
|
||||
var onError = opts.onError || _toastError;
|
||||
// Lazy authFetch lookup — shared/auth.js is loaded before this
|
||||
// module in production, but being lazy avoids surprising
|
||||
// construction-order failures and keeps the test stub (which
|
||||
// defines window.authFetch later) working.
|
||||
function _authFetch(url, init) {
|
||||
var fn = opts.authFetch || window.authFetch;
|
||||
return fn(url, init);
|
||||
}
|
||||
var pending = new Map();
|
||||
|
||||
function renderChip(info) {
|
||||
var chip = document.createElement("span");
|
||||
chip.className = "composer-chip composer-chip-" + (info.kind || "other");
|
||||
chip.setAttribute("role", "listitem");
|
||||
chip.dataset.attachmentId = info.attachment_id;
|
||||
|
||||
var icon = document.createElement("span");
|
||||
icon.className = "composer-chip-icon";
|
||||
icon.setAttribute("aria-hidden", "true");
|
||||
icon.textContent = info.kind === "image" ? "🖼" : "📄";
|
||||
chip.appendChild(icon);
|
||||
|
||||
var name = document.createElement("span");
|
||||
name.className = "composer-chip-name";
|
||||
name.textContent = info.filename || "(unnamed)";
|
||||
name.title = info.filename || "";
|
||||
chip.appendChild(name);
|
||||
|
||||
var size = document.createElement("span");
|
||||
size.className = "composer-chip-size";
|
||||
size.textContent = formatSize(info.size_bytes || 0);
|
||||
chip.appendChild(size);
|
||||
|
||||
var btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "composer-chip-remove";
|
||||
btn.setAttribute(
|
||||
"aria-label",
|
||||
"Remove attachment " + (info.filename || ""),
|
||||
);
|
||||
btn.title = "Remove";
|
||||
btn.textContent = "×";
|
||||
btn.addEventListener("click", function () {
|
||||
remove(info.attachment_id);
|
||||
});
|
||||
chip.appendChild(btn);
|
||||
|
||||
chipsEl.appendChild(chip);
|
||||
return chip;
|
||||
}
|
||||
|
||||
function _toastError(msg) {
|
||||
if (typeof root.toast !== "undefined" && root.toast.error) {
|
||||
root.toast.error(msg);
|
||||
}
|
||||
function _findChip(id) {
|
||||
return chipsEl.querySelector('[data-attachment-id="' + id + '"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} opts
|
||||
* chipsEl: HTMLElement — chips render target (composer.chipsEl).
|
||||
* getWsId: () => string — current workstream id (function so the
|
||||
* interactive pane can swap tabs without re-instantiating).
|
||||
* authFetch: optional override (default window.authFetch).
|
||||
* onError: optional (msg, err) => void — replaces the default toast
|
||||
* for upload failures.
|
||||
*/
|
||||
function createAttachmentController(opts) {
|
||||
if (!opts || !opts.chipsEl)
|
||||
throw new Error("createAttachmentController: chipsEl required");
|
||||
if (typeof opts.getWsId !== "function")
|
||||
throw new Error("createAttachmentController: getWsId must be a function");
|
||||
var chipsEl = opts.chipsEl;
|
||||
var getWsId = opts.getWsId;
|
||||
var onError = opts.onError || _toastError;
|
||||
// Lazy authFetch lookup — shared/auth.js is loaded before this
|
||||
// module in production, but being lazy avoids surprising
|
||||
// construction-order failures and keeps the test stub (which
|
||||
// defines window.authFetch later) working.
|
||||
function _authFetch(url, init) {
|
||||
var fn = opts.authFetch || root.authFetch;
|
||||
return fn(url, init);
|
||||
}
|
||||
var pending = new Map();
|
||||
function _removeChipDom(id) {
|
||||
var chip = _findChip(id);
|
||||
if (chip) chip.remove();
|
||||
}
|
||||
|
||||
function renderChip(info) {
|
||||
var chip = document.createElement("span");
|
||||
chip.className = "composer-chip composer-chip-" + (info.kind || "other");
|
||||
chip.setAttribute("role", "listitem");
|
||||
// Replace one Map key with another in place, preserving insertion
|
||||
// order. JS Map iteration is insertion-ordered, so naïve
|
||||
// `delete + set` would push the entry to the end of the order —
|
||||
// breaking the contract that send() iterates chips in user-
|
||||
// selection order. Localised here so callers (and the swap path
|
||||
// below) don't restate the rationale.
|
||||
function _replaceMapKey(map, oldKey, newKey, newVal) {
|
||||
var rebuilt = new Map();
|
||||
map.forEach(function (val, key) {
|
||||
if (key === oldKey) rebuilt.set(newKey, newVal);
|
||||
else rebuilt.set(key, val);
|
||||
});
|
||||
map.clear();
|
||||
rebuilt.forEach(function (val, key) {
|
||||
map.set(key, val);
|
||||
});
|
||||
}
|
||||
|
||||
function _swapPlaceholder(placeholderId, info) {
|
||||
// If the user removed the placeholder mid-upload (chip + map
|
||||
// entry both gone), drop the response — resurrecting a chip the
|
||||
// user dismissed would attach an untracked element (not in the
|
||||
// map, so coordSend wouldn't include it) and confuse them.
|
||||
if (!pending.has(placeholderId)) return;
|
||||
_replaceMapKey(pending, placeholderId, info.attachment_id, info);
|
||||
|
||||
var chip = _findChip(placeholderId);
|
||||
if (chip) {
|
||||
chip.dataset.attachmentId = info.attachment_id;
|
||||
|
||||
var icon = document.createElement("span");
|
||||
icon.className = "composer-chip-icon";
|
||||
icon.setAttribute("aria-hidden", "true");
|
||||
icon.textContent = info.kind === "image" ? "🖼" : "📄";
|
||||
chip.appendChild(icon);
|
||||
|
||||
var name = document.createElement("span");
|
||||
name.className = "composer-chip-name";
|
||||
name.textContent = info.filename || "(unnamed)";
|
||||
name.title = info.filename || "";
|
||||
chip.appendChild(name);
|
||||
|
||||
var size = document.createElement("span");
|
||||
size.className = "composer-chip-size";
|
||||
size.textContent = formatSize(info.size_bytes || 0);
|
||||
chip.appendChild(size);
|
||||
|
||||
var btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "composer-chip-remove";
|
||||
btn.setAttribute(
|
||||
"aria-label",
|
||||
"Remove attachment " + (info.filename || ""),
|
||||
);
|
||||
btn.title = "Remove";
|
||||
btn.textContent = "×";
|
||||
btn.addEventListener("click", function () {
|
||||
remove(info.attachment_id);
|
||||
});
|
||||
chip.appendChild(btn);
|
||||
|
||||
chipsEl.appendChild(chip);
|
||||
return chip;
|
||||
}
|
||||
|
||||
function _findChip(id) {
|
||||
return chipsEl.querySelector('[data-attachment-id="' + id + '"]');
|
||||
}
|
||||
|
||||
function _removeChipDom(id) {
|
||||
var chip = _findChip(id);
|
||||
if (chip) chip.remove();
|
||||
}
|
||||
|
||||
// Replace one Map key with another in place, preserving insertion
|
||||
// order. JS Map iteration is insertion-ordered, so naïve
|
||||
// `delete + set` would push the entry to the end of the order —
|
||||
// breaking the contract that send() iterates chips in user-
|
||||
// selection order. Localised here so callers (and the swap path
|
||||
// below) don't restate the rationale.
|
||||
function _replaceMapKey(map, oldKey, newKey, newVal) {
|
||||
var rebuilt = new Map();
|
||||
map.forEach(function (val, key) {
|
||||
if (key === oldKey) rebuilt.set(newKey, newVal);
|
||||
else rebuilt.set(key, val);
|
||||
});
|
||||
map.clear();
|
||||
rebuilt.forEach(function (val, key) {
|
||||
map.set(key, val);
|
||||
});
|
||||
}
|
||||
|
||||
function _swapPlaceholder(placeholderId, info) {
|
||||
// If the user removed the placeholder mid-upload (chip + map
|
||||
// entry both gone), drop the response — resurrecting a chip the
|
||||
// user dismissed would attach an untracked element (not in the
|
||||
// map, so coordSend wouldn't include it) and confuse them.
|
||||
if (!pending.has(placeholderId)) return;
|
||||
_replaceMapKey(pending, placeholderId, info.attachment_id, info);
|
||||
|
||||
var chip = _findChip(placeholderId);
|
||||
if (chip) {
|
||||
chip.dataset.attachmentId = info.attachment_id;
|
||||
var name = chip.querySelector(".composer-chip-name");
|
||||
if (name) {
|
||||
name.textContent = info.filename || "(unnamed)";
|
||||
name.title = info.filename || "";
|
||||
}
|
||||
var size = chip.querySelector(".composer-chip-size");
|
||||
if (size) size.textContent = formatSize(info.size_bytes || 0);
|
||||
} else {
|
||||
renderChip(info);
|
||||
var name = chip.querySelector(".composer-chip-name");
|
||||
if (name) {
|
||||
name.textContent = info.filename || "(unnamed)";
|
||||
name.title = info.filename || "";
|
||||
}
|
||||
var size = chip.querySelector(".composer-chip-size");
|
||||
if (size) size.textContent = formatSize(info.size_bytes || 0);
|
||||
} else {
|
||||
renderChip(info);
|
||||
}
|
||||
}
|
||||
|
||||
function upload(file) {
|
||||
var wsId = getWsId();
|
||||
if (!wsId || !file) return;
|
||||
var fd = new FormData();
|
||||
fd.append("file", file, file.name);
|
||||
function upload(file) {
|
||||
var wsId = getWsId();
|
||||
if (!wsId || !file) return;
|
||||
var fd = new FormData();
|
||||
fd.append("file", file, file.name);
|
||||
|
||||
var placeholderId = "__uploading_" + Date.now() + "_" + Math.random();
|
||||
var placeholder = {
|
||||
attachment_id: placeholderId,
|
||||
filename: file.name,
|
||||
size_bytes: file.size,
|
||||
mime_type: file.type || "",
|
||||
kind: (file.type || "").indexOf("image/") === 0 ? "image" : "text",
|
||||
uploading: true,
|
||||
};
|
||||
pending.set(placeholderId, placeholder);
|
||||
renderChip(placeholder);
|
||||
var placeholderId = "__uploading_" + Date.now() + "_" + Math.random();
|
||||
var placeholder = {
|
||||
attachment_id: placeholderId,
|
||||
filename: file.name,
|
||||
size_bytes: file.size,
|
||||
mime_type: file.type || "",
|
||||
kind: (file.type || "").indexOf("image/") === 0 ? "image" : "text",
|
||||
uploading: true,
|
||||
};
|
||||
pending.set(placeholderId, placeholder);
|
||||
renderChip(placeholder);
|
||||
|
||||
_authFetch(
|
||||
"/v1/api/workstreams/" + encodeURIComponent(wsId) + "/attachments",
|
||||
{ method: "POST", credentials: "include", body: fd },
|
||||
)
|
||||
.then(function (r) {
|
||||
return r.json().then(function (body) {
|
||||
return { ok: r.ok, status: r.status, body: body };
|
||||
});
|
||||
})
|
||||
.then(function (res) {
|
||||
if (!res.ok) {
|
||||
_removeChipDom(placeholderId);
|
||||
pending.delete(placeholderId);
|
||||
onError((res.body && res.body.error) || "Upload failed");
|
||||
return;
|
||||
}
|
||||
_swapPlaceholder(placeholderId, res.body);
|
||||
})
|
||||
.catch(function (e) {
|
||||
_authFetch(
|
||||
"/v1/api/workstreams/" + encodeURIComponent(wsId) + "/attachments",
|
||||
{ method: "POST", credentials: "include", body: fd },
|
||||
)
|
||||
.then(function (r) {
|
||||
return r.json().then(function (body) {
|
||||
return { ok: r.ok, status: r.status, body: body };
|
||||
});
|
||||
})
|
||||
.then(function (res) {
|
||||
if (!res.ok) {
|
||||
_removeChipDom(placeholderId);
|
||||
pending.delete(placeholderId);
|
||||
if (e && e.message !== "auth") onError("Upload failed", e);
|
||||
});
|
||||
}
|
||||
|
||||
function remove(attachmentId) {
|
||||
var info = pending.get(attachmentId);
|
||||
if (!info) return;
|
||||
_removeChipDom(attachmentId);
|
||||
pending.delete(attachmentId);
|
||||
if (info.uploading) return; // no server-side row yet
|
||||
var wsId = getWsId();
|
||||
if (!wsId) return;
|
||||
_authFetch(
|
||||
"/v1/api/workstreams/" +
|
||||
encodeURIComponent(wsId) +
|
||||
"/attachments/" +
|
||||
encodeURIComponent(attachmentId),
|
||||
{ method: "DELETE", credentials: "include" },
|
||||
).catch(function () {
|
||||
// Non-fatal — chip is gone client-side; the row will be
|
||||
// garbage-collected by the attachment GC sweep.
|
||||
});
|
||||
}
|
||||
|
||||
function clearChips() {
|
||||
pending.clear();
|
||||
chipsEl.textContent = "";
|
||||
}
|
||||
|
||||
function rehydrate() {
|
||||
var wsId = getWsId();
|
||||
if (!wsId) return Promise.resolve();
|
||||
return _authFetch(
|
||||
"/v1/api/workstreams/" + encodeURIComponent(wsId) + "/attachments",
|
||||
{ method: "GET", credentials: "include" },
|
||||
)
|
||||
.then(function (r) {
|
||||
return r.ok ? r.json() : null;
|
||||
})
|
||||
.then(function (body) {
|
||||
if (!body) return;
|
||||
// Tab swap mid-fetch: a stale response would clobber the new
|
||||
// tab's chips with the old tab's data. Re-check the live
|
||||
// wsId before mutating any DOM.
|
||||
if (getWsId() !== wsId) return;
|
||||
clearChips();
|
||||
(body.attachments || []).forEach(function (a) {
|
||||
pending.set(a.attachment_id, a);
|
||||
renderChip(a);
|
||||
});
|
||||
})
|
||||
.catch(function () {
|
||||
/* non-fatal */
|
||||
});
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
var attachments = [];
|
||||
var ids = [];
|
||||
pending.forEach(function (info, id) {
|
||||
if (info && !info.uploading) {
|
||||
attachments.push(info);
|
||||
ids.push(id);
|
||||
onError((res.body && res.body.error) || "Upload failed");
|
||||
return;
|
||||
}
|
||||
_swapPlaceholder(placeholderId, res.body);
|
||||
})
|
||||
.catch(function (e) {
|
||||
_removeChipDom(placeholderId);
|
||||
pending.delete(placeholderId);
|
||||
if (e && e.message !== "auth") onError("Upload failed", e);
|
||||
});
|
||||
return { attachments: attachments, attachment_ids: ids };
|
||||
}
|
||||
|
||||
function consume(attachedIds, droppedIds) {
|
||||
if (Array.isArray(attachedIds)) {
|
||||
attachedIds.forEach(function (id) {
|
||||
_removeChipDom(id);
|
||||
pending.delete(id);
|
||||
});
|
||||
if (Array.isArray(droppedIds) && droppedIds.length) {
|
||||
onError(
|
||||
"Some attachments couldn’t be included (" +
|
||||
droppedIds.length +
|
||||
") — they’re still in your composer.",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
clearChips();
|
||||
}
|
||||
}
|
||||
|
||||
function isEmpty() {
|
||||
return pending.size === 0;
|
||||
}
|
||||
|
||||
return {
|
||||
upload: upload,
|
||||
remove: remove,
|
||||
clearChips: clearChips,
|
||||
rehydrate: rehydrate,
|
||||
snapshot: snapshot,
|
||||
consume: consume,
|
||||
isEmpty: isEmpty,
|
||||
};
|
||||
}
|
||||
|
||||
root.createAttachmentController = createAttachmentController;
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
function remove(attachmentId) {
|
||||
var info = pending.get(attachmentId);
|
||||
if (!info) return;
|
||||
_removeChipDom(attachmentId);
|
||||
pending.delete(attachmentId);
|
||||
if (info.uploading) return; // no server-side row yet
|
||||
var wsId = getWsId();
|
||||
if (!wsId) return;
|
||||
_authFetch(
|
||||
"/v1/api/workstreams/" +
|
||||
encodeURIComponent(wsId) +
|
||||
"/attachments/" +
|
||||
encodeURIComponent(attachmentId),
|
||||
{ method: "DELETE", credentials: "include" },
|
||||
).catch(function () {
|
||||
// Non-fatal — chip is gone client-side; the row will be
|
||||
// garbage-collected by the attachment GC sweep.
|
||||
});
|
||||
}
|
||||
|
||||
function clearChips() {
|
||||
pending.clear();
|
||||
chipsEl.textContent = "";
|
||||
}
|
||||
|
||||
function rehydrate() {
|
||||
var wsId = getWsId();
|
||||
if (!wsId) return Promise.resolve();
|
||||
return _authFetch(
|
||||
"/v1/api/workstreams/" + encodeURIComponent(wsId) + "/attachments",
|
||||
{ method: "GET", credentials: "include" },
|
||||
)
|
||||
.then(function (r) {
|
||||
return r.ok ? r.json() : null;
|
||||
})
|
||||
.then(function (body) {
|
||||
if (!body) return;
|
||||
// Tab swap mid-fetch: a stale response would clobber the new
|
||||
// tab's chips with the old tab's data. Re-check the live
|
||||
// wsId before mutating any DOM.
|
||||
if (getWsId() !== wsId) return;
|
||||
clearChips();
|
||||
(body.attachments || []).forEach(function (a) {
|
||||
pending.set(a.attachment_id, a);
|
||||
renderChip(a);
|
||||
});
|
||||
})
|
||||
.catch(function () {
|
||||
/* non-fatal */
|
||||
});
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
var attachments = [];
|
||||
var ids = [];
|
||||
pending.forEach(function (info, id) {
|
||||
if (info && !info.uploading) {
|
||||
attachments.push(info);
|
||||
ids.push(id);
|
||||
}
|
||||
});
|
||||
return { attachments: attachments, attachment_ids: ids };
|
||||
}
|
||||
|
||||
function consume(attachedIds, droppedIds) {
|
||||
if (Array.isArray(attachedIds)) {
|
||||
attachedIds.forEach(function (id) {
|
||||
_removeChipDom(id);
|
||||
pending.delete(id);
|
||||
});
|
||||
if (Array.isArray(droppedIds) && droppedIds.length) {
|
||||
onError(
|
||||
"Some attachments couldn’t be included (" +
|
||||
droppedIds.length +
|
||||
") — they’re still in your composer.",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
clearChips();
|
||||
}
|
||||
}
|
||||
|
||||
function isEmpty() {
|
||||
return pending.size === 0;
|
||||
}
|
||||
|
||||
return {
|
||||
upload: upload,
|
||||
remove: remove,
|
||||
clearChips: clearChips,
|
||||
rehydrate: rehydrate,
|
||||
snapshot: snapshot,
|
||||
consume: consume,
|
||||
isEmpty: isEmpty,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// --- Legacy window bridge ---------------------------------------------------
|
||||
// Still-classic consumers reach this as a global at event/boot time (after
|
||||
// this deferred module evaluated). New module code imports instead.
|
||||
window.createAttachmentController = createAttachmentController;
|
||||
|
||||
@@ -48,182 +48,181 @@
|
||||
* Caller invokes once per busy → idle transition. Strips queued
|
||||
* styling from every bubble and then fires the onIdle hook.
|
||||
*/
|
||||
(function (root) {
|
||||
"use strict";
|
||||
|
||||
function createQueueController(opts) {
|
||||
if (!opts || !opts.messagesEl)
|
||||
throw new Error("createQueueController: messagesEl required");
|
||||
if (typeof opts.getWsId !== "function")
|
||||
throw new Error("createQueueController: getWsId must be a function");
|
||||
var messagesEl = opts.messagesEl;
|
||||
var getWsId = opts.getWsId;
|
||||
var wrapInBody = !!opts.wrapInBody;
|
||||
var onAfterDequeue =
|
||||
typeof opts.onAfterDequeue === "function" ? opts.onAfterDequeue : null;
|
||||
var onIdle = typeof opts.onIdle === "function" ? opts.onIdle : null;
|
||||
// Lazy authFetch lookup — see composer_attachments.js for the
|
||||
// rationale; same load-order robustness applies here.
|
||||
function _authFetch(url, init) {
|
||||
var fn = opts.authFetch || root.authFetch;
|
||||
return fn(url, init);
|
||||
}
|
||||
|
||||
function _scrollIntoView() {
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
}
|
||||
|
||||
function _deleteRequest(msgId) {
|
||||
var wsId = getWsId();
|
||||
if (!wsId || !msgId) return null;
|
||||
return _authFetch(
|
||||
"/v1/api/workstreams/" + encodeURIComponent(wsId) + "/send",
|
||||
{
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ msg_id: msgId }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Fire-and-forget DELETE — used by bind() on a raced-away bubble
|
||||
// where the caller has no DOM follow-up. Still invokes
|
||||
// onAfterDequeue on success: the server-side reservation release
|
||||
// freed any attachments the queued message held, and the caller
|
||||
// typically rehydrates its chip pile so the user can reuse them.
|
||||
function _sendDelete(msgId) {
|
||||
var p = _deleteRequest(msgId);
|
||||
if (!p) return;
|
||||
p.then(function () {
|
||||
if (onAfterDequeue) onAfterDequeue();
|
||||
}).catch(function () {
|
||||
/* network error — promote loop strips queued styling on idle */
|
||||
});
|
||||
}
|
||||
|
||||
function addQueuedMessage(text, priority) {
|
||||
var el = document.createElement("div");
|
||||
el.className = "msg user msg-queued";
|
||||
el.setAttribute("role", "status");
|
||||
var important = priority === "important";
|
||||
if (important) {
|
||||
el.classList.add("msg-queued-important");
|
||||
el.setAttribute("aria-label", "Important message queued: " + text);
|
||||
} else {
|
||||
el.setAttribute("aria-label", "Message queued: " + text);
|
||||
}
|
||||
|
||||
var badge = document.createElement("span");
|
||||
badge.className = "queued-badge";
|
||||
badge.setAttribute("aria-hidden", "true");
|
||||
badge.textContent = important ? "queued (!!!) " : "queued ";
|
||||
|
||||
var textNode = document.createTextNode(text);
|
||||
|
||||
var dismiss = document.createElement("button");
|
||||
dismiss.type = "button";
|
||||
dismiss.className = "queued-dismiss";
|
||||
dismiss.title = "Remove from queue";
|
||||
dismiss.setAttribute("aria-label", "Remove queued message");
|
||||
dismiss.textContent = "×";
|
||||
dismiss.addEventListener("click", function (e) {
|
||||
e.stopPropagation();
|
||||
dequeue(el);
|
||||
});
|
||||
|
||||
var host;
|
||||
if (wrapInBody) {
|
||||
host = document.createElement("div");
|
||||
host.className = "msg-body";
|
||||
el.appendChild(host);
|
||||
} else {
|
||||
host = el;
|
||||
}
|
||||
host.appendChild(badge);
|
||||
host.appendChild(textNode);
|
||||
host.appendChild(dismiss);
|
||||
|
||||
messagesEl.appendChild(el);
|
||||
_scrollIntoView();
|
||||
return el;
|
||||
}
|
||||
|
||||
// Dismiss flow:
|
||||
// - msg_id known → DELETE /send, optimistically remove on success.
|
||||
// - msg_id not yet bound → mark pendingDismiss; bind() picks it up
|
||||
// when the send response arrives.
|
||||
function dequeue(el) {
|
||||
var msgId = el.dataset.msgId;
|
||||
if (!msgId) {
|
||||
el.dataset.pendingDismiss = "true";
|
||||
el.remove();
|
||||
return;
|
||||
}
|
||||
var p = _deleteRequest(msgId);
|
||||
if (!p) return;
|
||||
p.then(function (r) {
|
||||
return r.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
if (data && data.status === "removed") el.remove();
|
||||
if (onAfterDequeue) onAfterDequeue();
|
||||
})
|
||||
.catch(function () {
|
||||
/* network error — promote loop strips queued styling on idle */
|
||||
});
|
||||
}
|
||||
|
||||
// Server returned status:queued + msg_id. Stamps msgId onto the
|
||||
// bubble, OR releases the slot server-side when the bubble can no
|
||||
// longer be dequeued from the UI (user dismissed pre-bind, or the
|
||||
// promote sweep raced ahead and stripped .msg-queued / its dismiss
|
||||
// button). Caller need only invoke; the controller handles all
|
||||
// three races without further callbacks.
|
||||
function bind(el, msgId) {
|
||||
if (!el || !msgId) return;
|
||||
var racedAway =
|
||||
el.dataset.pendingDismiss || !el.classList.contains("msg-queued");
|
||||
if (racedAway) {
|
||||
_sendDelete(msgId);
|
||||
return;
|
||||
}
|
||||
el.dataset.msgId = msgId;
|
||||
}
|
||||
|
||||
function remove(el) {
|
||||
if (el && el.parentNode) el.remove();
|
||||
}
|
||||
|
||||
// Caller invokes onIdleEdge() exactly once per busy → idle
|
||||
// transition. The controller strips queued styling from every
|
||||
// bubble (so optimistic queues render as normal user messages
|
||||
// once the worker has drained them) and then fires the optional
|
||||
// onIdle hook so the consumer can run its own edge-only cleanup
|
||||
// (e.g. clearing the cancel/force-stop timers) without each
|
||||
// consumer re-implementing the same edge-detection logic.
|
||||
function onIdleEdge() {
|
||||
var queued = messagesEl.querySelectorAll(".msg-queued");
|
||||
queued.forEach(function (el) {
|
||||
el.classList.remove("msg-queued", "msg-queued-important");
|
||||
delete el.dataset.msgId;
|
||||
el.removeAttribute("role");
|
||||
el.removeAttribute("aria-label");
|
||||
var badge = el.querySelector(".queued-badge");
|
||||
if (badge) badge.remove();
|
||||
var dismiss = el.querySelector(".queued-dismiss");
|
||||
if (dismiss) dismiss.remove();
|
||||
});
|
||||
if (onIdle) onIdle();
|
||||
}
|
||||
|
||||
return {
|
||||
addQueuedMessage: addQueuedMessage,
|
||||
bind: bind,
|
||||
remove: remove,
|
||||
onIdleEdge: onIdleEdge,
|
||||
};
|
||||
export function createQueueController(opts) {
|
||||
if (!opts || !opts.messagesEl)
|
||||
throw new Error("createQueueController: messagesEl required");
|
||||
if (typeof opts.getWsId !== "function")
|
||||
throw new Error("createQueueController: getWsId must be a function");
|
||||
var messagesEl = opts.messagesEl;
|
||||
var getWsId = opts.getWsId;
|
||||
var wrapInBody = !!opts.wrapInBody;
|
||||
var onAfterDequeue =
|
||||
typeof opts.onAfterDequeue === "function" ? opts.onAfterDequeue : null;
|
||||
var onIdle = typeof opts.onIdle === "function" ? opts.onIdle : null;
|
||||
// Lazy authFetch lookup — see composer_attachments.js for the
|
||||
// rationale; same load-order robustness applies here.
|
||||
function _authFetch(url, init) {
|
||||
var fn = opts.authFetch || window.authFetch;
|
||||
return fn(url, init);
|
||||
}
|
||||
|
||||
root.createQueueController = createQueueController;
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
function _scrollIntoView() {
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
}
|
||||
|
||||
function _deleteRequest(msgId) {
|
||||
var wsId = getWsId();
|
||||
if (!wsId || !msgId) return null;
|
||||
return _authFetch(
|
||||
"/v1/api/workstreams/" + encodeURIComponent(wsId) + "/send",
|
||||
{
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ msg_id: msgId }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Fire-and-forget DELETE — used by bind() on a raced-away bubble
|
||||
// where the caller has no DOM follow-up. Still invokes
|
||||
// onAfterDequeue on success: the server-side reservation release
|
||||
// freed any attachments the queued message held, and the caller
|
||||
// typically rehydrates its chip pile so the user can reuse them.
|
||||
function _sendDelete(msgId) {
|
||||
var p = _deleteRequest(msgId);
|
||||
if (!p) return;
|
||||
p.then(function () {
|
||||
if (onAfterDequeue) onAfterDequeue();
|
||||
}).catch(function () {
|
||||
/* network error — promote loop strips queued styling on idle */
|
||||
});
|
||||
}
|
||||
|
||||
function addQueuedMessage(text, priority) {
|
||||
var el = document.createElement("div");
|
||||
el.className = "msg user msg-queued";
|
||||
el.setAttribute("role", "status");
|
||||
var important = priority === "important";
|
||||
if (important) {
|
||||
el.classList.add("msg-queued-important");
|
||||
el.setAttribute("aria-label", "Important message queued: " + text);
|
||||
} else {
|
||||
el.setAttribute("aria-label", "Message queued: " + text);
|
||||
}
|
||||
|
||||
var badge = document.createElement("span");
|
||||
badge.className = "queued-badge";
|
||||
badge.setAttribute("aria-hidden", "true");
|
||||
badge.textContent = important ? "queued (!!!) " : "queued ";
|
||||
|
||||
var textNode = document.createTextNode(text);
|
||||
|
||||
var dismiss = document.createElement("button");
|
||||
dismiss.type = "button";
|
||||
dismiss.className = "queued-dismiss";
|
||||
dismiss.title = "Remove from queue";
|
||||
dismiss.setAttribute("aria-label", "Remove queued message");
|
||||
dismiss.textContent = "×";
|
||||
dismiss.addEventListener("click", function (e) {
|
||||
e.stopPropagation();
|
||||
dequeue(el);
|
||||
});
|
||||
|
||||
var host;
|
||||
if (wrapInBody) {
|
||||
host = document.createElement("div");
|
||||
host.className = "msg-body";
|
||||
el.appendChild(host);
|
||||
} else {
|
||||
host = el;
|
||||
}
|
||||
host.appendChild(badge);
|
||||
host.appendChild(textNode);
|
||||
host.appendChild(dismiss);
|
||||
|
||||
messagesEl.appendChild(el);
|
||||
_scrollIntoView();
|
||||
return el;
|
||||
}
|
||||
|
||||
// Dismiss flow:
|
||||
// - msg_id known → DELETE /send, optimistically remove on success.
|
||||
// - msg_id not yet bound → mark pendingDismiss; bind() picks it up
|
||||
// when the send response arrives.
|
||||
function dequeue(el) {
|
||||
var msgId = el.dataset.msgId;
|
||||
if (!msgId) {
|
||||
el.dataset.pendingDismiss = "true";
|
||||
el.remove();
|
||||
return;
|
||||
}
|
||||
var p = _deleteRequest(msgId);
|
||||
if (!p) return;
|
||||
p.then(function (r) {
|
||||
return r.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
if (data && data.status === "removed") el.remove();
|
||||
if (onAfterDequeue) onAfterDequeue();
|
||||
})
|
||||
.catch(function () {
|
||||
/* network error — promote loop strips queued styling on idle */
|
||||
});
|
||||
}
|
||||
|
||||
// Server returned status:queued + msg_id. Stamps msgId onto the
|
||||
// bubble, OR releases the slot server-side when the bubble can no
|
||||
// longer be dequeued from the UI (user dismissed pre-bind, or the
|
||||
// promote sweep raced ahead and stripped .msg-queued / its dismiss
|
||||
// button). Caller need only invoke; the controller handles all
|
||||
// three races without further callbacks.
|
||||
function bind(el, msgId) {
|
||||
if (!el || !msgId) return;
|
||||
var racedAway =
|
||||
el.dataset.pendingDismiss || !el.classList.contains("msg-queued");
|
||||
if (racedAway) {
|
||||
_sendDelete(msgId);
|
||||
return;
|
||||
}
|
||||
el.dataset.msgId = msgId;
|
||||
}
|
||||
|
||||
function remove(el) {
|
||||
if (el && el.parentNode) el.remove();
|
||||
}
|
||||
|
||||
// Caller invokes onIdleEdge() exactly once per busy → idle
|
||||
// transition. The controller strips queued styling from every
|
||||
// bubble (so optimistic queues render as normal user messages
|
||||
// once the worker has drained them) and then fires the optional
|
||||
// onIdle hook so the consumer can run its own edge-only cleanup
|
||||
// (e.g. clearing the cancel/force-stop timers) without each
|
||||
// consumer re-implementing the same edge-detection logic.
|
||||
function onIdleEdge() {
|
||||
var queued = messagesEl.querySelectorAll(".msg-queued");
|
||||
queued.forEach(function (el) {
|
||||
el.classList.remove("msg-queued", "msg-queued-important");
|
||||
delete el.dataset.msgId;
|
||||
el.removeAttribute("role");
|
||||
el.removeAttribute("aria-label");
|
||||
var badge = el.querySelector(".queued-badge");
|
||||
if (badge) badge.remove();
|
||||
var dismiss = el.querySelector(".queued-dismiss");
|
||||
if (dismiss) dismiss.remove();
|
||||
});
|
||||
if (onIdle) onIdle();
|
||||
}
|
||||
|
||||
return {
|
||||
addQueuedMessage: addQueuedMessage,
|
||||
bind: bind,
|
||||
remove: remove,
|
||||
onIdleEdge: onIdleEdge,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Legacy window bridge ---------------------------------------------------
|
||||
// Still-classic consumers reach this as a global at event/boot time (after
|
||||
// this deferred module evaluated). New module code imports instead.
|
||||
window.createQueueController = createQueueController;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 };
|
||||
@@ -11,13 +11,10 @@
|
||||
// session living on a cluster node (the LOCALITY invariant).
|
||||
//
|
||||
// ES module — the first legacy pane lifted into a real module (step 5a; the
|
||||
// coordinator pane followed in 5e.0). The shared substrate it leans on —
|
||||
// composer/renderer/etc. — is still classic, consumed as globals. The console
|
||||
// shell.js imports the factory directly; the standalone loads it as
|
||||
// `<script type="module">`. It also publishes `window.InteractivePane` /
|
||||
// `window.createInteractivePane` so the still-classic standalone `app.js`
|
||||
// shell can read the class — safe because app.js builds panes only AFTER the
|
||||
// workstream fetch resolves, well after this deferred module has executed.
|
||||
// coordinator pane followed in 5e.0, and the shared substrate it leans on —
|
||||
// composer/renderer/auth/etc. — became modules too, imported below). The
|
||||
// console shell.js imports the factory directly; the standalone loads it as
|
||||
// `<script type="module">`.
|
||||
//
|
||||
// House style: programmatic DOM, NO innerHTML (the renderer is the sole
|
||||
// sanctioned exception); pane code root-scopes to its own element.
|
||||
@@ -37,6 +34,14 @@ import {
|
||||
batchKicker,
|
||||
indexLabel,
|
||||
} from "./conversation.js";
|
||||
import { authFetch } from "./auth.js";
|
||||
import { showToast } from "./toast.js";
|
||||
import { Composer } from "./composer.js";
|
||||
import { createAttachmentController } from "./composer_attachments.js";
|
||||
import { createQueueController } from "./composer_queue.js";
|
||||
import { StatusBar } from "./status_bar.js";
|
||||
import { streamingRender, streamingRenderFinalize } from "./renderer.js";
|
||||
import { setMarkdown, operatorSourceLabel } from "./utils.js";
|
||||
|
||||
let _paneCounter = 0;
|
||||
|
||||
@@ -125,11 +130,6 @@ function _toolAnnounceText(items) {
|
||||
// throws on a shell-only seam. The standalone shell (app.js) and the console
|
||||
// factory (createInteractivePane) each pass a richer host.
|
||||
const INTERACTIVE_DEFAULT_HOST = {
|
||||
// Workstream display name — the surrounding shell knows it; a bare pane falls
|
||||
// back to a short id (see updateWsName).
|
||||
getWsName() {
|
||||
return null;
|
||||
},
|
||||
// Is THIS pane the user's current focus? Gates focus-stealing so a caret is
|
||||
// never yanked into a backgrounded pane. A lone pane is always focused.
|
||||
isFocused() {
|
||||
@@ -139,6 +139,9 @@ const INTERACTIVE_DEFAULT_HOST = {
|
||||
// bare/console pane relies on it, so this is a no-op. The standalone focused
|
||||
// pane additionally refetches + reassigns the ws list (see app.js).
|
||||
onStreamError() {},
|
||||
// EventSource (re)opened — the dual of onStreamError. The console pane host
|
||||
// uses it to reset its terminal-failure counter (see createInteractivePane).
|
||||
onStreamOpen() {},
|
||||
// Where the ``--skip-permissions`` banner lands (standalone: #ui-header).
|
||||
warningTarget(pane) {
|
||||
return pane.messagesEl;
|
||||
@@ -155,19 +158,17 @@ class Pane {
|
||||
this.wsId = wsId || null;
|
||||
// Transport + host seam. ``base`` is the node-proxy URL prefix ("" for a
|
||||
// local session, "/node/{id}" when the console proxies a session that lives
|
||||
// on a cluster node — the LOCALITY invariant). ``embedded`` drops the
|
||||
// standalone split-pane chrome (focus tracking, context menu, split/close
|
||||
// buttons) for the L-shell's tab + slim header. ``host`` supplies the few
|
||||
// things only the surrounding shell knows (workstream name, which pane is
|
||||
// focused, the stream-error recovery policy, the warning-banner target);
|
||||
// see the standalone adapter in app.js and createInteractivePane below.
|
||||
// on a cluster node — the LOCALITY invariant). ``host`` supplies the few
|
||||
// things only the surrounding shell knows (which pane is focused, the
|
||||
// stream-error recovery policy, the warning-banner target); see
|
||||
// createInteractivePane below. Every pane is L-shell-hosted — the
|
||||
// standalone split-pane chrome (focus tracking, context menu, header with
|
||||
// split/close buttons) was retired with the step-6 fork collapse.
|
||||
this._base = opts.base || "";
|
||||
this._embedded = !!opts.embedded;
|
||||
this._host = opts.host || INTERACTIVE_DEFAULT_HOST;
|
||||
this._onClose = typeof opts.onClose === "function" ? opts.onClose : null;
|
||||
this.evtSource = null;
|
||||
this.el = null;
|
||||
this.headerEl = null;
|
||||
this.messagesEl = null;
|
||||
this.inputEl = null;
|
||||
this.sendBtn = null;
|
||||
@@ -223,16 +224,6 @@ class Pane {
|
||||
this._stopTTS();
|
||||
}
|
||||
|
||||
updateWsName() {
|
||||
if (!this.headerEl) return; // no header in the embedded L-shell pane
|
||||
const nameEl = this.headerEl.querySelector(".pane-ws-name");
|
||||
if (nameEl) {
|
||||
nameEl.textContent = this.wsId
|
||||
? this._host.getWsName(this.wsId) || this.wsId.substring(0, 8)
|
||||
: "";
|
||||
}
|
||||
}
|
||||
|
||||
disconnectSSE() {
|
||||
if (this._cancelTimeout) {
|
||||
clearTimeout(this._cancelTimeout);
|
||||
@@ -602,8 +593,7 @@ class Pane {
|
||||
|
||||
_createDOM() {
|
||||
this.el = document.createElement("div");
|
||||
this.el.className = "pane";
|
||||
if (this._embedded) this.el.classList.add("pane--embedded");
|
||||
this.el.className = "pane pane--embedded";
|
||||
this.el.dataset.paneId = this.id;
|
||||
|
||||
// Approval keyboard shortcuts. The converged card advertises y/n/a (+Enter/
|
||||
@@ -640,98 +630,12 @@ class Pane {
|
||||
}
|
||||
});
|
||||
|
||||
// Standalone split-pane affordances: focus tracking + the right-click
|
||||
// split/close menu. In the L-shell the tab bar owns focus and the per-tab
|
||||
// action menu, so an embedded pane wires none of it.
|
||||
if (!this._embedded) {
|
||||
// Focus on mousedown (before child clicks)
|
||||
this.el.addEventListener("mousedown", () => {
|
||||
setFocusedPane(this.id);
|
||||
});
|
||||
// Also track keyboard focus moving into this pane (e.g. Tab into textarea)
|
||||
this.el.addEventListener(
|
||||
"focusin",
|
||||
() => {
|
||||
setFocusedPane(this.id);
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
// Right-click context menu for split/close actions — skip interactive
|
||||
// elements (textareas, links, buttons) so native copy/paste works
|
||||
this.el.addEventListener("contextmenu", (e) => {
|
||||
const tag = e.target.tagName;
|
||||
if (
|
||||
tag === "TEXTAREA" ||
|
||||
tag === "INPUT" ||
|
||||
tag === "A" ||
|
||||
tag === "BUTTON" ||
|
||||
e.target.isContentEditable
|
||||
)
|
||||
return;
|
||||
const sel = window.getSelection();
|
||||
if (sel && sel.toString().length > 0) return;
|
||||
e.preventDefault();
|
||||
setFocusedPane(this.id);
|
||||
showPaneContextMenu(e.clientX, e.clientY, this.id);
|
||||
});
|
||||
}
|
||||
|
||||
// No pane header in the L-shell (embedded): the workstream name, persona,
|
||||
// and state are shown by the tab and the rail (Workspaces). The standalone
|
||||
// split-pane (retired in step 6) still builds a header for its split/close
|
||||
// actions. The --skip-permissions banner lands in messagesEl now (see the
|
||||
// host warningTarget), not the header.
|
||||
if (!this._embedded) {
|
||||
this.headerEl = document.createElement("div");
|
||||
this.headerEl.className = "pane-header";
|
||||
|
||||
const wsName = document.createElement("span");
|
||||
wsName.className = "pane-ws-name";
|
||||
wsName.textContent = this.wsId
|
||||
? this._host.getWsName(this.wsId) || this.wsId.substring(0, 8)
|
||||
: "";
|
||||
this.headerEl.appendChild(wsName);
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "pane-actions";
|
||||
|
||||
const splitRightBtn = document.createElement("button");
|
||||
splitRightBtn.className = "pane-action-btn";
|
||||
splitRightBtn.title = "Split right";
|
||||
splitRightBtn.setAttribute("aria-label", "Split right");
|
||||
splitRightBtn.textContent = "\u2502";
|
||||
splitRightBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
splitPane(this.id, "horizontal");
|
||||
};
|
||||
actions.appendChild(splitRightBtn);
|
||||
|
||||
const splitDownBtn = document.createElement("button");
|
||||
splitDownBtn.className = "pane-action-btn";
|
||||
splitDownBtn.title = "Split down";
|
||||
splitDownBtn.setAttribute("aria-label", "Split down");
|
||||
splitDownBtn.textContent = "\u2500";
|
||||
splitDownBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
splitPane(this.id, "vertical");
|
||||
};
|
||||
actions.appendChild(splitDownBtn);
|
||||
|
||||
const closeBtn = document.createElement("button");
|
||||
closeBtn.className = "pane-action-btn pane-close-btn";
|
||||
closeBtn.title = "Close pane";
|
||||
closeBtn.setAttribute("aria-label", "Close pane");
|
||||
closeBtn.textContent = "\u00d7";
|
||||
closeBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
if (countLeaves(splitRoot) > 1) closePane(this.id);
|
||||
};
|
||||
actions.appendChild(closeBtn);
|
||||
|
||||
this.headerEl.appendChild(actions);
|
||||
this.el.appendChild(this.headerEl);
|
||||
}
|
||||
// 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
|
||||
// chrome that used to live here (focus tracking, right-click context menu,
|
||||
// a header with split/close buttons) was retired with the step-6 fork
|
||||
// collapse.
|
||||
|
||||
// Messages area
|
||||
this.messagesEl = document.createElement("div");
|
||||
@@ -877,6 +781,7 @@ class Pane {
|
||||
this.retryDelay = 1000;
|
||||
this.statusBarEl.classList.remove("ws-sb-disconnected");
|
||||
if (this._lastStatusEvt) this.updateStatus(this._lastStatusEvt);
|
||||
this._host.onStreamOpen(this);
|
||||
};
|
||||
|
||||
this.evtSource.onmessage = (e) => {
|
||||
@@ -3266,26 +3171,43 @@ function createInteractivePane(root, wsId, opts) {
|
||||
let active = false;
|
||||
let connected = false;
|
||||
let recoverTimer = null;
|
||||
// Terminal-failure tracking. Native EventSource auto-reconnect (plus the 5s
|
||||
// CLOSED-state recovery below) covers transient drops — but a session that is
|
||||
// GONE from its node (closed/evicted, node restarted, re-homed) 404s every
|
||||
// reconnect forever. After 3 consecutive CLOSED checks the controller gives
|
||||
// up: stream closed, timers dropped, status bar terminal, `opts.onDead()`
|
||||
// fired ONCE so the shell can paint its reconnect affordance. Recovery is
|
||||
// the shell's revive path (re-resolve node + POST /open + rebuild) — never
|
||||
// automatic, so a deliberately-closed session is not resurrected by a timer.
|
||||
// A successful stream open resets the counter (host.onStreamOpen).
|
||||
let dead = false;
|
||||
let failCount = 0;
|
||||
|
||||
const giveUp = function () {
|
||||
if (dead) return;
|
||||
dead = true;
|
||||
failCount = 0;
|
||||
if (recoverTimer) {
|
||||
clearTimeout(recoverTimer);
|
||||
recoverTimer = null;
|
||||
}
|
||||
// Invalidate any in-flight history load: its .finally would otherwise
|
||||
// reopen a stream for a session we just declared dead.
|
||||
pane._historyLoadToken = (pane._historyLoadToken || 0) + 1;
|
||||
pane.disconnectSSE();
|
||||
// Terminal wording — the transient error path says "Reconnecting…".
|
||||
pane.statusBarEl.classList.add("ws-sb-disconnected");
|
||||
pane._sbTokens.textContent = "Disconnected";
|
||||
if (typeof opts.onDead === "function") {
|
||||
try {
|
||||
opts.onDead();
|
||||
} catch (e) {
|
||||
console.error("interactive pane: onDead callback failed", e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const host = {
|
||||
// Workstream name from the Tier-1 cluster snapshot the shell owns, else the
|
||||
// name the opener passed; Pane falls back to a short id.
|
||||
getWsName(id) {
|
||||
try {
|
||||
const TS = window.TS_APP;
|
||||
const cs = TS && TS.getClusterState && TS.getClusterState();
|
||||
if (cs && cs.nodes) {
|
||||
for (const nid in cs.nodes) {
|
||||
for (const ws of cs.nodes[nid].workstreams || []) {
|
||||
if (ws.id === id) return ws.name || ws.title || null;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
/* snapshot not ready yet */
|
||||
}
|
||||
return opts.name || null;
|
||||
},
|
||||
// Only the visible tab steals focus — never yank the caret into a
|
||||
// backgrounded pane mid background-replay.
|
||||
isFocused() {
|
||||
@@ -3295,18 +3217,29 @@ function createInteractivePane(root, wsId, opts) {
|
||||
// deliberately does not close the source on error). Guard the terminal
|
||||
// case: if the source is genuinely CLOSED after a beat, open a fresh
|
||||
// same-ws stream. No global ws-list refetch — a console pane owns one ws.
|
||||
// Three consecutive CLOSED beats = the session is gone, not flaky: give up
|
||||
// (a dead session would otherwise be 404-polled every 5s indefinitely).
|
||||
onStreamError(pane) {
|
||||
if (dead) return;
|
||||
if (recoverTimer) clearTimeout(recoverTimer);
|
||||
recoverTimer = setTimeout(() => {
|
||||
recoverTimer = null;
|
||||
if (
|
||||
!pane.evtSource ||
|
||||
pane.evtSource.readyState === EventSource.CLOSED
|
||||
pane.evtSource &&
|
||||
pane.evtSource.readyState !== EventSource.CLOSED
|
||||
) {
|
||||
pane.connectSSE(pane.wsId);
|
||||
return; // native reconnect is still working the problem
|
||||
}
|
||||
failCount += 1;
|
||||
if (failCount >= 3) giveUp();
|
||||
else pane.connectSSE(pane.wsId);
|
||||
}, 5000);
|
||||
},
|
||||
// Stream (re)opened — the session is reachable again; reset the give-up
|
||||
// counter so unrelated future blips get a fresh allowance.
|
||||
onStreamOpen() {
|
||||
failCount = 0;
|
||||
},
|
||||
// The --skip-permissions banner lands in the pane's own slim header.
|
||||
warningTarget(pane) {
|
||||
return pane.messagesEl;
|
||||
@@ -3318,7 +3251,6 @@ function createInteractivePane(root, wsId, opts) {
|
||||
};
|
||||
|
||||
const pane = new Pane(wsId, {
|
||||
embedded: true,
|
||||
base,
|
||||
host,
|
||||
onClose: opts.onClose,
|
||||
@@ -3328,6 +3260,10 @@ function createInteractivePane(root, wsId, opts) {
|
||||
return {
|
||||
wsId: wsId,
|
||||
pane: pane,
|
||||
// The transport base this controller talks through ("" local, "/node/{id}"
|
||||
// proxied) — the shell's tab-menu verbs aim at the SAME backend the pane
|
||||
// streams from.
|
||||
base: base,
|
||||
// First activation opens the Tier-2 stream (REST history first, then live);
|
||||
// re-activations just re-mark focus. Idempotent — the shell calls it on
|
||||
// every tab switch.
|
||||
@@ -3343,10 +3279,18 @@ function createInteractivePane(root, wsId, opts) {
|
||||
deactivate() {
|
||||
active = false;
|
||||
},
|
||||
// Re-auth fan-out: reconnect the stream.
|
||||
// Re-auth fan-out: reconnect the stream. A dead controller stays dead —
|
||||
// recovery is the shell's revive path (which may need a different node).
|
||||
onLogin() {
|
||||
if (connected) pane._loadHistoryThenConnect(wsId);
|
||||
if (connected && !dead) pane._loadHistoryThenConnect(wsId);
|
||||
},
|
||||
// Terminal-state surface for the shell: `isDead()` gates revive-vs-connect
|
||||
// on activate/reopen; `markDead()` lets Tier-1 lifecycle (ws_closed) stop
|
||||
// the retry loop NOW instead of after three failed beats.
|
||||
isDead() {
|
||||
return dead;
|
||||
},
|
||||
markDead: giveUp,
|
||||
// Full teardown — close the stream + all timers/recording/tts, drop the
|
||||
// recovery timer, detach the DOM. A backgrounded pane must not leak an
|
||||
// upstream node connection.
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
/* Shared keyboard shortcuts overlay — turnstone design system
|
||||
Configure: window.TURNSTONE_KB_SHORTCUTS = [{title, keys: [{desc, badge}]}] */
|
||||
Configure: window.TURNSTONE_KB_SHORTCUTS = [{title, keys: [{desc, badge}]}]
|
||||
ES module; the "?" / Escape document listener registers at module eval. */
|
||||
|
||||
import { escapeHtml, setSafeHtml } from "./utils.js";
|
||||
|
||||
let _kbPreviousFocus = null;
|
||||
|
||||
function showKbHelp() {
|
||||
export function showKbHelp() {
|
||||
_kbPreviousFocus = document.activeElement;
|
||||
const existing = document.getElementById("kb-overlay");
|
||||
if (existing) existing.remove();
|
||||
@@ -35,7 +38,7 @@ function showKbHelp() {
|
||||
document.getElementById("kb-box").focus();
|
||||
}
|
||||
|
||||
function hideKbHelp() {
|
||||
export function hideKbHelp() {
|
||||
const el = document.getElementById("kb-overlay");
|
||||
if (el) el.remove();
|
||||
if (_kbPreviousFocus && _kbPreviousFocus.focus) {
|
||||
@@ -62,3 +65,8 @@ document.addEventListener("keydown", function (e) {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// --- Legacy window bridge ---------------------------------------------------
|
||||
// Still-classic consumers reach this as a global at event/boot time (after
|
||||
// this deferred module evaluated). New module code imports instead.
|
||||
Object.assign(window, { showKbHelp, hideKbHelp });
|
||||
|
||||
+194
-102
@@ -47,10 +47,159 @@ export class ShellPane {
|
||||
onActivate() {}
|
||||
/** Pane left the visible tab — keep-or-teardown is per-type. */
|
||||
onDeactivate() {}
|
||||
/** An openPane() call targeted this ALREADY-OPEN pane — explicit user intent
|
||||
* (saved-list resume, rail row, child link), distinct from onActivate which
|
||||
* also fires on plain tab switches and only on a pane CHANGE. Conversational
|
||||
* panes use this to revive a dead session even when the pane is already the
|
||||
* active tab. `extra` is the caller's open-time hint (e.g. `{nodeId}`). */
|
||||
onReopen(extra) {}
|
||||
/** Pane is being destroyed — release resources (close streams, timers). */
|
||||
onClose() {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic popup menu on the shared `.tab-menu` chrome — items, positioning,
|
||||
* dismissal (Escape/Tab, outside-mousedown), and arrow-key roving in one
|
||||
* place. Used by the PaneManager's tab-action dropdown and the shell's
|
||||
* footer user menu (one menu vocabulary, one behaviour).
|
||||
*
|
||||
* `items` are `{label, key?, cls?, separator?, action}`. `opts`:
|
||||
* - label: the menu's aria-label.
|
||||
* - cls: extra class(es) on the `.tab-menu` element.
|
||||
* - prefer: "down" (default) opens under the anchor, flipping up on
|
||||
* overflow; "up" the reverse (e.g. a viewport-bottom chip).
|
||||
* - align: "end" (default) right-aligns to the anchor; "start" left.
|
||||
* - expandEl: element whose aria-expanded mirrors the menu (often the
|
||||
* anchor's host button).
|
||||
* - returnFocusEl: focus target when the menu closes via keyboard.
|
||||
* - ignoreEl: outside-mousedown ignore region (defaults to the anchor).
|
||||
* - onClose: cleanup notification (fires exactly once).
|
||||
*
|
||||
* Returns `{ menu, close }`; `close()` is idempotent.
|
||||
*/
|
||||
export function openPopupMenu(anchor, items, opts) {
|
||||
opts = opts || {};
|
||||
const menu = document.createElement("div");
|
||||
menu.className = "tab-menu" + (opts.cls ? " " + opts.cls : "");
|
||||
menu.setAttribute("role", "menu");
|
||||
if (opts.label) menu.setAttribute("aria-label", opts.label);
|
||||
|
||||
let closed = false;
|
||||
const close = () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
document.removeEventListener("keydown", onKey);
|
||||
document.removeEventListener("mousedown", onDown);
|
||||
if (menu.parentNode) menu.parentNode.removeChild(menu);
|
||||
if (opts.expandEl && opts.expandEl.isConnected)
|
||||
opts.expandEl.setAttribute("aria-expanded", "false");
|
||||
if (opts.onClose) opts.onClose();
|
||||
};
|
||||
|
||||
for (const item of items) {
|
||||
if (item.separator) {
|
||||
const sep = document.createElement("div");
|
||||
sep.className = "tab-menu-sep";
|
||||
sep.setAttribute("role", "separator");
|
||||
menu.append(sep);
|
||||
continue;
|
||||
}
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "tab-menu-item" + (item.cls ? " " + item.cls : "");
|
||||
btn.setAttribute("role", "menuitem");
|
||||
btn.tabIndex = -1;
|
||||
const label = document.createElement("span");
|
||||
label.className = "tab-menu-label";
|
||||
label.textContent = item.label;
|
||||
btn.append(label);
|
||||
if (item.key) {
|
||||
const key = document.createElement("span");
|
||||
key.className = "tab-menu-key";
|
||||
key.setAttribute("aria-hidden", "true"); // a visual hint, not the action
|
||||
key.textContent = item.key;
|
||||
btn.append(key);
|
||||
}
|
||||
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) {
|
||||
console.error("popup menu: action failed", item.label, e);
|
||||
}
|
||||
});
|
||||
menu.append(btn);
|
||||
}
|
||||
document.body.append(menu);
|
||||
|
||||
// Position fixed against the anchor; flip on overflow, clamp to viewport.
|
||||
const ar = anchor.getBoundingClientRect();
|
||||
const mr = menu.getBoundingClientRect();
|
||||
let x = opts.align === "start" ? ar.left : ar.right - mr.width;
|
||||
if (x < 4) x = 4;
|
||||
if (x + mr.width > window.innerWidth) x = window.innerWidth - mr.width - 4;
|
||||
let y;
|
||||
if (opts.prefer === "up") {
|
||||
y = ar.top - mr.height - 4;
|
||||
if (y < 4) y = ar.bottom + 4;
|
||||
} else {
|
||||
y = ar.bottom + 2;
|
||||
if (y + mr.height > window.innerHeight) y = ar.top - mr.height - 2;
|
||||
if (y < 4) y = 4; // never strand the menu above the viewport (short window)
|
||||
}
|
||||
menu.style.left = x + "px";
|
||||
menu.style.top = y + "px";
|
||||
if (opts.expandEl) opts.expandEl.setAttribute("aria-expanded", "true");
|
||||
|
||||
const onKey = (e) => {
|
||||
const btns = Array.from(menu.querySelectorAll(".tab-menu-item"));
|
||||
if (e.key === "Escape" || e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
close();
|
||||
if (opts.returnFocusEl) opts.returnFocusEl.focus();
|
||||
} else if (
|
||||
e.key === "ArrowDown" ||
|
||||
e.key === "ArrowUp" ||
|
||||
e.key === "Home" ||
|
||||
e.key === "End"
|
||||
) {
|
||||
e.preventDefault();
|
||||
if (!btns.length) return;
|
||||
// idx -1 = no item focused (a click on a separator / the menu surface
|
||||
// moves focus off the items without closing). ArrowDown's modulo
|
||||
// already enters at the top then; ArrowUp must enter at the BOTTOM —
|
||||
// unguarded, (-1 - 1 + n) % n lands on the second-to-last item.
|
||||
const idx = btns.indexOf(document.activeElement);
|
||||
if (e.key === "ArrowDown") btns[(idx + 1) % btns.length].focus();
|
||||
else if (e.key === "ArrowUp")
|
||||
btns[
|
||||
idx < 0 ? btns.length - 1 : (idx - 1 + btns.length) % btns.length
|
||||
].focus();
|
||||
else if (e.key === "Home") btns[0].focus();
|
||||
else btns[btns.length - 1].focus();
|
||||
}
|
||||
};
|
||||
const ignoreEl = opts.ignoreEl || anchor;
|
||||
const onDown = (e) => {
|
||||
if (!menu.contains(e.target) && !ignoreEl.contains(e.target)) close();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
// Defer the outside-mousedown attach a tick so the opening click that
|
||||
// bubbled to document doesn't immediately re-close the menu.
|
||||
setTimeout(() => {
|
||||
if (!closed) document.addEventListener("mousedown", onDown);
|
||||
}, 0);
|
||||
const first = menu.querySelector(".tab-menu-item");
|
||||
if (first) first.focus();
|
||||
return { menu, close };
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the tab bar + pane host. `openPane(type, id?)` is create-or-focus;
|
||||
* singletons are keyed by `type`, multi-instance panes by `type:id`.
|
||||
@@ -108,6 +257,14 @@ export class PaneManager {
|
||||
return this._panes.has(paneId);
|
||||
}
|
||||
|
||||
/** The open pane for (type, id), or null — lets the shell reach a pane for
|
||||
* cross-cutting lifecycle signals (e.g. Tier-1 ws_closed → mark its session
|
||||
* controller dead). Omit id for a singleton. */
|
||||
getPane(type, id) {
|
||||
const paneId = id == null ? type : type + ":" + id;
|
||||
return this._panes.get(paneId) || null;
|
||||
}
|
||||
|
||||
/** The active pane's identity ({type, rawId}), or null — lets the rail mark
|
||||
* the workspace row that mirrors the active tab. */
|
||||
getActive() {
|
||||
@@ -180,6 +337,7 @@ export class PaneManager {
|
||||
}
|
||||
const paneId = id == null ? type : type + ":" + id;
|
||||
let pane = this._panes.get(paneId);
|
||||
const existed = !!pane;
|
||||
if (!pane) {
|
||||
// Auth gate runs only on CREATE — a denied pane is never built (the backend
|
||||
// enforces the scope too; this just avoids opening a doomed pane).
|
||||
@@ -198,6 +356,18 @@ export class PaneManager {
|
||||
this._mount(pane);
|
||||
}
|
||||
this.activate(paneId);
|
||||
// Explicit-reopen signal: openPane on an existing pane is a user saying
|
||||
// "open this AGAIN" (saved-list resume, rail row, child link) — activate()
|
||||
// alone can't carry that (it no-ops hooks on the already-active pane, and
|
||||
// onActivate also fires on plain tab switches). Fired AFTER activate so
|
||||
// the pane is visible when it reacts (e.g. revives a dead session).
|
||||
if (existed) {
|
||||
try {
|
||||
pane.onReopen(extra);
|
||||
} catch (e) {
|
||||
console.error("PaneManager: onReopen failed", paneId, e);
|
||||
}
|
||||
}
|
||||
return pane;
|
||||
}
|
||||
|
||||
@@ -330,7 +500,12 @@ export class PaneManager {
|
||||
tab.append(g);
|
||||
pane._glyphEl = g;
|
||||
}
|
||||
const titleNode = document.createTextNode(pane.title);
|
||||
// The title is a span (not a bare text node) so CSS can ellipsize it —
|
||||
// tabs cap their width (tighter on mobile) instead of growing unbounded
|
||||
// with a long workstream name.
|
||||
const titleNode = document.createElement("span");
|
||||
titleNode.className = "tab-title";
|
||||
titleNode.textContent = pane.title;
|
||||
tab.append(titleNode);
|
||||
pane._titleNode = titleNode; // setTabTitle repaints this from Tier-1
|
||||
// Tab-action menu (step 7): a pane that exposes `tabMenu()` gets a caret to
|
||||
@@ -404,8 +579,8 @@ export class PaneManager {
|
||||
|
||||
/** Open a pane's tab-action dropdown, anchored under its caret. Generic: the
|
||||
* item set comes from `pane.tabMenu()` (wired per type in the shell); the
|
||||
* PaneManager owns only the chrome + keyboard + positioning. Singleton menu —
|
||||
* opening one closes any other. Items are
|
||||
* chrome + keyboard + positioning live in the shared openPopupMenu helper.
|
||||
* Singleton menu — opening one closes any other. Items are
|
||||
* `{label, key?, cls?, separator?, action}`. */
|
||||
_openTabMenu(tab, pane) {
|
||||
this._closeTabMenu();
|
||||
@@ -417,109 +592,26 @@ export class PaneManager {
|
||||
return;
|
||||
}
|
||||
if (!items.length) return;
|
||||
|
||||
const menu = document.createElement("div");
|
||||
menu.className = "tab-menu";
|
||||
menu.setAttribute("role", "menu");
|
||||
menu.setAttribute("aria-label", (pane.title || "Pane") + " actions");
|
||||
for (const item of items) {
|
||||
if (item.separator) {
|
||||
const sep = document.createElement("div");
|
||||
sep.className = "tab-menu-sep";
|
||||
sep.setAttribute("role", "separator");
|
||||
menu.append(sep);
|
||||
continue;
|
||||
}
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "tab-menu-item" + (item.cls ? " " + item.cls : "");
|
||||
btn.setAttribute("role", "menuitem");
|
||||
btn.tabIndex = -1;
|
||||
const label = document.createElement("span");
|
||||
label.className = "tab-menu-label";
|
||||
label.textContent = item.label;
|
||||
btn.append(label);
|
||||
if (item.key) {
|
||||
const key = document.createElement("span");
|
||||
key.className = "tab-menu-key";
|
||||
key.setAttribute("aria-hidden", "true"); // a visual hint, not the action
|
||||
key.textContent = item.key;
|
||||
btn.append(key);
|
||||
}
|
||||
btn.addEventListener("click", () => {
|
||||
this._closeTabMenu();
|
||||
try {
|
||||
item.action();
|
||||
} catch (e) {
|
||||
console.error("PaneManager: tab action failed", item.label, e);
|
||||
}
|
||||
});
|
||||
menu.append(btn);
|
||||
}
|
||||
document.body.append(menu);
|
||||
|
||||
// Position fixed, right-aligned under the caret; flip up / clamp on overflow.
|
||||
const anchor = tab.querySelector(".tab-caret") || tab;
|
||||
const ar = anchor.getBoundingClientRect();
|
||||
const mr = menu.getBoundingClientRect();
|
||||
let x = ar.right - mr.width;
|
||||
let y = ar.bottom + 2;
|
||||
if (x < 4) x = 4;
|
||||
if (x + mr.width > window.innerWidth) x = window.innerWidth - mr.width - 4;
|
||||
if (y + mr.height > window.innerHeight) y = ar.top - mr.height - 2;
|
||||
if (y < 4) y = 4; // never strand the menu above the viewport (short window)
|
||||
menu.style.left = x + "px";
|
||||
menu.style.top = y + "px";
|
||||
tab.setAttribute("aria-expanded", "true");
|
||||
|
||||
const onKey = (e) => {
|
||||
const btns = Array.from(menu.querySelectorAll(".tab-menu-item"));
|
||||
if (e.key === "Escape" || e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
this._closeTabMenu();
|
||||
tab.focus();
|
||||
} else if (
|
||||
e.key === "ArrowDown" ||
|
||||
e.key === "ArrowUp" ||
|
||||
e.key === "Home" ||
|
||||
e.key === "End"
|
||||
) {
|
||||
e.preventDefault();
|
||||
if (!btns.length) return;
|
||||
const idx = btns.indexOf(document.activeElement);
|
||||
if (e.key === "ArrowDown") btns[(idx + 1) % btns.length].focus();
|
||||
else if (e.key === "ArrowUp")
|
||||
btns[(idx - 1 + btns.length) % btns.length].focus();
|
||||
else if (e.key === "Home") btns[0].focus();
|
||||
else btns[btns.length - 1].focus();
|
||||
}
|
||||
};
|
||||
const onDown = (e) => {
|
||||
if (!menu.contains(e.target) && !tab.contains(e.target))
|
||||
this._closeTabMenu();
|
||||
};
|
||||
this._openMenu = { menu, tab, onKey, onDown };
|
||||
document.addEventListener("keydown", onKey);
|
||||
// Defer the outside-mousedown attach a tick so the opening click that
|
||||
// bubbled to document doesn't immediately re-close the menu.
|
||||
setTimeout(() => {
|
||||
if (this._openMenu && this._openMenu.menu === menu)
|
||||
document.addEventListener("mousedown", onDown);
|
||||
}, 0);
|
||||
const first = menu.querySelector(".tab-menu-item");
|
||||
if (first) first.focus();
|
||||
const handle = openPopupMenu(
|
||||
tab.querySelector(".tab-caret") || tab,
|
||||
items,
|
||||
{
|
||||
label: (pane.title || "Pane") + " actions",
|
||||
expandEl: tab,
|
||||
returnFocusEl: tab,
|
||||
ignoreEl: tab, // a click elsewhere on the tab is menu-adjacent, not "outside"
|
||||
onClose: () => {
|
||||
if (this._openMenu && this._openMenu.handle === handle)
|
||||
this._openMenu = null;
|
||||
},
|
||||
},
|
||||
);
|
||||
this._openMenu = { handle };
|
||||
}
|
||||
|
||||
/** Tear down the open tab-action dropdown (if any) + its document listeners. */
|
||||
_closeTabMenu() {
|
||||
const m = this._openMenu;
|
||||
if (!m) return;
|
||||
this._openMenu = null;
|
||||
document.removeEventListener("keydown", m.onKey);
|
||||
document.removeEventListener("mousedown", m.onDown);
|
||||
if (m.menu.parentNode) m.menu.parentNode.removeChild(m.menu);
|
||||
if (m.tab && m.tab.isConnected)
|
||||
m.tab.setAttribute("aria-expanded", "false");
|
||||
if (this._openMenu) this._openMenu.handle.close(); // onClose clears the ref
|
||||
}
|
||||
|
||||
_persist() {
|
||||
|
||||
@@ -84,10 +84,16 @@ function renderCluster(root, cs, TS) {
|
||||
pill.type = "button";
|
||||
pill.className = "cpill";
|
||||
pill.setAttribute("aria-label", n + " " + STATE_LABEL[st] + ", filter");
|
||||
pill.title = n + " " + STATE_LABEL[st]; // names the glyph+count when the rail is collapsed
|
||||
pill.append(glyph(st));
|
||||
const b = document.createElement("b");
|
||||
b.textContent = String(n);
|
||||
pill.append(b, document.createTextNode(" " + STATE_LABEL[st]));
|
||||
// The label rides in a span so the collapsed rail can hide it and keep the
|
||||
// glyph + count (a bare text node is not CSS-addressable).
|
||||
const lbl = document.createElement("span");
|
||||
lbl.className = "cpill-label";
|
||||
lbl.textContent = " " + STATE_LABEL[st];
|
||||
pill.append(b, lbl);
|
||||
pill.addEventListener(
|
||||
"click",
|
||||
() => TS.drillDownByState && TS.drillDownByState(st),
|
||||
@@ -148,6 +154,7 @@ function renderCluster(root, cs, TS) {
|
||||
item.type = "button";
|
||||
item.className = "node-row";
|
||||
const st = nodeState(info);
|
||||
item.title = info.node_id; // the .nn label ellipsizes long node ids
|
||||
item.setAttribute(
|
||||
"aria-label",
|
||||
info.node_id + ", " + st + ", " + info.ws_total + " workstreams",
|
||||
@@ -197,6 +204,9 @@ function sessionRow(ws, childCount, isChild, TS, paneManager, active) {
|
||||
"aria-label",
|
||||
(ws.name || ws.title || ws.id) + ", " + (ws.state || "idle") + ", " + kind,
|
||||
);
|
||||
// Hover name — the expanded row ellipsizes long names; the collapsed rail
|
||||
// shows only the state glyph, so the title is the whole label there.
|
||||
btn.title = ws.name || ws.title || ws.id || "session";
|
||||
btn.append(glyph(ws.state || "idle"));
|
||||
const nm = document.createElement("span");
|
||||
nm.className = "nm";
|
||||
@@ -244,6 +254,7 @@ function renderWorkspaces(root, cs, TS, paneManager) {
|
||||
const dashLi = document.createElement("li");
|
||||
const dash = document.createElement("button");
|
||||
dash.type = "button";
|
||||
dash.title = "Dashboard"; // the collapsed rail shows only the ◇ glyph
|
||||
dash.className =
|
||||
"row" + (active && active.type === "dashboard" ? " open" : "");
|
||||
const g = document.createElement("span");
|
||||
@@ -332,6 +343,29 @@ export function mountManage(root, paneManager) {
|
||||
const allowed = TS.isTabAllowed || (() => true);
|
||||
root.replaceChildren();
|
||||
|
||||
// Collapsed-rail representation: the discovery groups need text, so the
|
||||
// collapsed strip shows ONE ⚙ row that opens/focuses the singleton Admin
|
||||
// pane at its current tab (the rail expands back to browse the full map).
|
||||
// Always in the DOM (CSS shows it only while the rail is collapsed), but
|
||||
// permission-gated like the groups: no visible tab, no ⚙ either.
|
||||
if (ia.some((g) => g.tabs.some((t) => allowed(t.tab)))) {
|
||||
const manageGlyph = document.createElement("button");
|
||||
manageGlyph.type = "button";
|
||||
manageGlyph.className = "row manage-glyph";
|
||||
manageGlyph.title = "Manage";
|
||||
manageGlyph.setAttribute("aria-label", "Manage (open admin)");
|
||||
const mg = document.createElement("span");
|
||||
mg.className = "glyph";
|
||||
mg.setAttribute("aria-hidden", "true");
|
||||
mg.textContent = "⚙";
|
||||
manageGlyph.append(mg);
|
||||
manageGlyph.addEventListener(
|
||||
"click",
|
||||
() => paneManager && paneManager.openPane("admin"),
|
||||
);
|
||||
root.append(manageGlyph);
|
||||
}
|
||||
|
||||
// If the Admin pane is already open (e.g. restored by PaneManager.rehydrate),
|
||||
// seed the rail to its current tab + expand the owning group; otherwise every
|
||||
// group starts collapsed and nothing is marked until the user clicks.
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
// renderer.js — Markdown + LaTeX rendering (no external deps except KaTeX)
|
||||
//
|
||||
// ES module (imports utils; vendor hljs/katex/mermaid stay lazy typeof-guarded
|
||||
// globals). Window bridge at the bottom for the still-classic consumers.
|
||||
|
||||
import { escapeHtml } from "./utils.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline formatting
|
||||
@@ -8,7 +13,7 @@
|
||||
var _SAFE_TAGS =
|
||||
/<(\/?(?:br|kbd|mark|sub|sup|ins|wbr|abbr|small|u|s))(?:\s*\/?)>/gi;
|
||||
|
||||
function inlineMarkdown(text) {
|
||||
export function inlineMarkdown(text) {
|
||||
// Escape HTML first so only tags we generate are real
|
||||
text = escapeHtml(text);
|
||||
// Restore safe HTML tags (attribute-free only — already escaped so no XSS)
|
||||
@@ -256,7 +261,7 @@ function _langToCssClass(lang) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main markdown renderer
|
||||
// ---------------------------------------------------------------------------
|
||||
function renderMarkdown(text) {
|
||||
export function renderMarkdown(text) {
|
||||
// Scope footnote IDs per top-level render call (prevents collisions across messages)
|
||||
if (_fnDepth === 0) _fnScopeId++;
|
||||
_fnDepth++;
|
||||
@@ -808,7 +813,7 @@ function _applyCachedHljs(el, cachedHtml) {
|
||||
el.classList.add("hljs");
|
||||
}
|
||||
|
||||
function postRenderHljs(containerEl) {
|
||||
export function postRenderHljs(containerEl) {
|
||||
if (typeof hljs === "undefined") return;
|
||||
if (!_hljsConfigured) {
|
||||
hljs.configure({ ignoreUnescapedHTML: true });
|
||||
@@ -853,7 +858,7 @@ function postRenderHljs(containerEl) {
|
||||
}
|
||||
}
|
||||
|
||||
function postRenderMarkdown(containerEl) {
|
||||
export function postRenderMarkdown(containerEl) {
|
||||
postRenderHljs(containerEl);
|
||||
// Render mermaid diagrams (lazy-loads mermaid.js on first use)
|
||||
postRenderMermaid(containerEl);
|
||||
@@ -1213,7 +1218,7 @@ function postRenderMermaid(containerEl) {
|
||||
});
|
||||
}
|
||||
|
||||
function reRenderAllMermaid() {
|
||||
export function reRenderAllMermaid() {
|
||||
if (_mermaidState !== "ready") return;
|
||||
_initMermaid();
|
||||
var els = document.querySelectorAll(
|
||||
@@ -1264,7 +1269,7 @@ function _streamingRenderApply(el, buffer) {
|
||||
}
|
||||
}
|
||||
|
||||
function streamingRender(el, buffer) {
|
||||
export function streamingRender(el, buffer) {
|
||||
if (!el) return;
|
||||
// Short-circuit identical-buffer calls (e.g. SSE retry / resume).
|
||||
// V8's string === length-compares internally so the explicit check is
|
||||
@@ -1283,7 +1288,7 @@ function streamingRender(el, buffer) {
|
||||
});
|
||||
}
|
||||
|
||||
function streamingRenderFinalize(el, buffer) {
|
||||
export function streamingRenderFinalize(el, buffer) {
|
||||
if (!el) return;
|
||||
// Flush any pending rAF-scheduled render so the finalize sees the
|
||||
// final buffer exactly once, then run the expensive post-render
|
||||
@@ -1297,3 +1302,16 @@ function streamingRenderFinalize(el, buffer) {
|
||||
postRenderMarkdown(el);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Legacy window bridge ---------------------------------------------------
|
||||
// Still-classic consumers reach these as globals at event/boot time (after
|
||||
// this deferred module evaluated). New module code imports instead.
|
||||
Object.assign(window, {
|
||||
renderMarkdown,
|
||||
inlineMarkdown,
|
||||
postRenderHljs,
|
||||
postRenderMarkdown,
|
||||
reRenderAllMermaid,
|
||||
streamingRender,
|
||||
streamingRenderFinalize,
|
||||
});
|
||||
|
||||
@@ -20,13 +20,13 @@
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* Responsive (step 7): DESKTOP-FIRST, by decision — this matches the desktop-only
|
||||
design-system scope (the console's mobile off-canvas drawer was retired in step
|
||||
3b, and the DS itself is desktop-only). The rail -> off-canvas drawer on mobile
|
||||
is DEFERRED, not dropped: it would slot in here as a `max-width` @media that
|
||||
collapses the 266px rail column into a toggled overlay. Narrow viewports get a
|
||||
cramped desktop layout (not a broken one) — no silent mobile-support claim.
|
||||
Revisit only if/when the DS takes on a mobile scope. */
|
||||
/* Responsive: desktop-first. Two rail modes beyond the default 266px column,
|
||||
both defined in the "Collapsed rail" / "Mobile drawer" sections at the end of
|
||||
this sheet:
|
||||
- DESKTOP COLLAPSE (user preference, `.app.rail-collapsed`): the rail
|
||||
shrinks to a 52px glyph-only strip — live state glyphs remain the
|
||||
navigation; text labels hide.
|
||||
- The deferred mobile off-canvas drawer (max-width media query). */
|
||||
|
||||
/* ===== Rail (the | of the L) ===== */
|
||||
.rail {
|
||||
@@ -81,6 +81,33 @@
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--r-sm);
|
||||
}
|
||||
/* collapse toggle — far side of the brand row. Desktop-only: the mobile
|
||||
drawer always presents the full rail, so the button hides there (see the
|
||||
responsive section at the end of this sheet). */
|
||||
.rail-collapse {
|
||||
margin-left: auto;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
background: none;
|
||||
border-radius: var(--r-sm);
|
||||
color: var(--ink-4);
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
.rail-collapse:hover {
|
||||
background: var(--panel-2);
|
||||
color: var(--ink-2);
|
||||
}
|
||||
.rail-collapse:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.rail-scroll {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
@@ -447,6 +474,15 @@
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
}
|
||||
/* The tabs live in their own strip (the role=tablist element — the burger and
|
||||
the [+] tail sit OUTSIDE it in .tabbar, see shell.js buildShell). On mobile
|
||||
the strip is the horizontal scroller, so those stay pinned. */
|
||||
.tabstrip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
.tab {
|
||||
position: relative;
|
||||
flex: none;
|
||||
@@ -462,6 +498,15 @@
|
||||
border: 1px solid transparent;
|
||||
background: none;
|
||||
font-family: var(--font-ui);
|
||||
/* long workstream names ellipsize (.tab-title) instead of growing the tab */
|
||||
max-width: 240px;
|
||||
}
|
||||
.tab-title {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Tab glyph spacing — applies to BOTH the static decorative char (Dashboard /
|
||||
Admin, via .glyph) and the live state glyph (.ui-glyph-* on conversational
|
||||
@@ -538,6 +583,39 @@
|
||||
gap: 8px;
|
||||
color: var(--ink-4);
|
||||
}
|
||||
/* Drawer toggle + backdrop — desktop keeps the rail in the grid, so both are
|
||||
dormant here; the mobile block at the end of this sheet brings them up. */
|
||||
.rail-burger {
|
||||
display: none;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--ink-3);
|
||||
border-radius: var(--r-sm);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
border: none;
|
||||
background: none;
|
||||
font-family: var(--font-ui);
|
||||
flex: none;
|
||||
}
|
||||
.rail-burger:hover {
|
||||
background: var(--panel-2);
|
||||
color: var(--ink);
|
||||
}
|
||||
.rail-burger:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.rail-scrim {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 390;
|
||||
background: color-mix(in srgb, var(--bg) 55%, transparent);
|
||||
}
|
||||
|
||||
/* Tab-action dropdown — promoted to the shared shell sheet so the console and
|
||||
the standalone server both render it. Recovers the retired `.ws-tab-dropdown`
|
||||
@@ -645,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
|
||||
@@ -727,35 +809,100 @@
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
/* Dead-session reconnect affordance (shell.js showDeadBanner) — a real <button>
|
||||
for keyboard/AT reach, restyled so native OS button chrome doesn't leak into
|
||||
the pane; reads as an error status line. Sits above the (dead) conversation. */
|
||||
.pane-dead-banner {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--hair);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
color: color-mix(in srgb, var(--err) 80%, var(--ink-2));
|
||||
}
|
||||
.pane-dead-banner:hover {
|
||||
color: var(--err);
|
||||
background: color-mix(in oklab, var(--err) 8%, transparent);
|
||||
}
|
||||
.pane-dead-banner:focus-visible {
|
||||
outline: 2px solid var(--err);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* ===== 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. */
|
||||
@@ -787,6 +934,12 @@
|
||||
.grp {
|
||||
margin-top: 1px;
|
||||
}
|
||||
/* The Manage section's collapsed-strip stand-in (rail.js mountManage): one ⚙
|
||||
row that opens the Admin pane. Hidden while the rail is expanded — the
|
||||
collapsed-rail block at the end of this sheet flips it on. */
|
||||
.manage-glyph {
|
||||
display: none;
|
||||
}
|
||||
.grp-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -855,3 +1008,188 @@
|
||||
/* inset off the tab-bar hairline + rail edge; .admin-content keeps its right pad */
|
||||
padding: 16px 0 0 16px;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Collapsed rail (desktop) — `.app.rail-collapsed` shrinks the | of the L to a
|
||||
52px glyph-only strip. The glyphs ARE the navigation: live session state
|
||||
glyphs (the same Tier-1-fed .ui-glyph-* the expanded rows carry), ◇ home,
|
||||
stacked cluster glyph+count pills, one ⚙ for Manage. Hover names come from
|
||||
the title attrs rail.js always sets. Desktop-only: below the drawer
|
||||
breakpoint the rail overlays at full width, so the preference lies dormant.
|
||||
|
||||
769 = the drawer's 768 breakpoint + 1 — a matched pair (CSS @media cannot
|
||||
read a shared token). Change BOTH together or a 1px band gets neither
|
||||
(or both) layouts.
|
||||
========================================================================== */
|
||||
@media (min-width: 769px) {
|
||||
.app.rail-collapsed {
|
||||
grid-template-columns: 52px 1fr;
|
||||
}
|
||||
/* brand stacks: mark (home) on top, the expand toggle under it */
|
||||
.app.rail-collapsed .rail-brand {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 13px 0 11px;
|
||||
}
|
||||
.app.rail-collapsed .brand-name,
|
||||
.app.rail-collapsed .brand-sub {
|
||||
display: none;
|
||||
}
|
||||
.app.rail-collapsed .brand-home {
|
||||
justify-content: center;
|
||||
}
|
||||
.app.rail-collapsed .rail-collapse {
|
||||
margin-left: 0;
|
||||
}
|
||||
.app.rail-collapsed .rail-scroll {
|
||||
padding: 10px 5px 6px;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
/* section labels become hairline separators (uppercase text won't fit) */
|
||||
.app.rail-collapsed .sec-label {
|
||||
height: 0;
|
||||
padding: 0;
|
||||
margin: 8px 6px;
|
||||
border-top: 1px solid var(--hair);
|
||||
overflow: hidden;
|
||||
font-size: 0;
|
||||
}
|
||||
/* …except the first, which would double the brand row's border-bottom
|
||||
(the conn slot sits between them whether or not #status-bar relocated) */
|
||||
.app.rail-collapsed .rail-conn + .sec-label,
|
||||
.app.rail-collapsed .rail-conn-slot + .sec-label {
|
||||
border-top: 0;
|
||||
margin: 0;
|
||||
}
|
||||
/* connection indicator: glyph-only — the message text collapses away but
|
||||
stays in the DOM for AT; expanding the rail reveals it */
|
||||
.app.rail-collapsed .rail-conn {
|
||||
font-size: 0;
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
}
|
||||
.app.rail-collapsed .rail-conn.disconnected::before {
|
||||
content: "⚠";
|
||||
font-size: 12px;
|
||||
color: var(--warn);
|
||||
}
|
||||
/* cluster card: pills stack as glyph + count; the node list needs text */
|
||||
.app.rail-collapsed .cluster {
|
||||
padding: 7px 4px;
|
||||
}
|
||||
.app.rail-collapsed .cluster-row {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.app.rail-collapsed .cpill-label {
|
||||
display: none;
|
||||
}
|
||||
.app.rail-collapsed .cluster-nodes {
|
||||
display: none;
|
||||
}
|
||||
/* session rows: the live state glyph is the whole row */
|
||||
.app.rail-collapsed .row {
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
padding: 7px 0;
|
||||
}
|
||||
.app.rail-collapsed .row .nm,
|
||||
.app.rail-collapsed .row .rcount,
|
||||
.app.rail-collapsed .row .tag {
|
||||
display: none;
|
||||
}
|
||||
.app.rail-collapsed .row.open::before {
|
||||
left: 1px;
|
||||
}
|
||||
/* children flatten to peer glyphs — the hierarchy needs text to read, and
|
||||
reachability beats hierarchy in a glyph strip (titles disambiguate) */
|
||||
.app.rail-collapsed .children {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border-left: 0;
|
||||
}
|
||||
/* Manage: the ⚙ stand-in replaces the text-only discovery groups */
|
||||
.app.rail-collapsed .grp {
|
||||
display: none;
|
||||
}
|
||||
.app.rail-collapsed .manage-glyph {
|
||||
display: flex;
|
||||
}
|
||||
/* footer stacks: theme toggle above the avatar-only user chip */
|
||||
.app.rail-collapsed .rail-foot {
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 9px 6px;
|
||||
}
|
||||
.app.rail-collapsed .user-chip {
|
||||
margin: 0;
|
||||
padding: 3px;
|
||||
}
|
||||
.app.rail-collapsed .user-name {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Mobile drawer — below the breakpoint the rail leaves the grid and overlays
|
||||
off-canvas at FULL width (the desktop collapse preference lies dormant: a
|
||||
52px strip makes no sense when the rail floats over the content anyway).
|
||||
☰ in the tab bar opens it; scrim tap / Escape / opening a pane close it.
|
||||
The closed drawer is visibility:hidden so its buttons drop out of the Tab
|
||||
order and the a11y tree — not merely translated off-screen.
|
||||
|
||||
768 pairs with the collapse block's min-width: 769px above — change BOTH
|
||||
together (see the note there).
|
||||
========================================================================== */
|
||||
@media (max-width: 768px) {
|
||||
.app,
|
||||
.app.rail-collapsed {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.rail {
|
||||
position: fixed;
|
||||
inset: 0 auto 0 0;
|
||||
width: min(286px, 84vw);
|
||||
z-index: 400;
|
||||
transform: translateX(-100%);
|
||||
visibility: hidden;
|
||||
/* visibility flips AFTER the slide-out finishes (delay = duration) */
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
visibility 0s linear 0.2s;
|
||||
}
|
||||
.app.rail-open .rail {
|
||||
transform: none;
|
||||
visibility: visible;
|
||||
transition: transform 0.2s ease;
|
||||
box-shadow: 0 0 32px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
/* the desktop collapse toggle hides — the drawer is always the full rail */
|
||||
.rail-collapse {
|
||||
display: none;
|
||||
}
|
||||
.rail-burger {
|
||||
display: inline-flex;
|
||||
}
|
||||
.app.rail-open .rail-scrim {
|
||||
display: block;
|
||||
}
|
||||
/* the tab strip scrolls horizontally (burger + [+] stay pinned); tabs
|
||||
tighten so 2-3 stay legible */
|
||||
.tabbar {
|
||||
padding: 8px 8px;
|
||||
}
|
||||
.tabstrip {
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.tab {
|
||||
max-width: 48vw;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.rail {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
+408
-120
@@ -2,12 +2,16 @@
|
||||
L-shell bootstrap — builds the unified rail + tab-bar + pane-host frame and
|
||||
hands off to the legacy app boot.
|
||||
|
||||
The FIRST ES-module citizen in shared_static (the rest are classic scripts).
|
||||
Being `type="module"` it is deferred, so it runs AFTER every classic script —
|
||||
including app.js, which now defines `window.TS_APP.boot` without auto-running
|
||||
it. So the order is: classic scripts define globals → this module builds the
|
||||
shell and reparents the existing DOM → it calls `TS_APP.boot()` to start
|
||||
login + the Tier-1 cluster stream under the shell.
|
||||
The shared substrate (utils/toast/auth/composer/renderer/…) is ES modules
|
||||
like this file; only theme.js (FOUC), the vendored libs, and the legacy
|
||||
app/admin/governance bundles remain classic. Being `type="module"` this
|
||||
file is deferred and last in document order, so it runs AFTER every classic
|
||||
script — including app.js, which defines `window.TS_APP.boot` without
|
||||
auto-running it — and after the substrate modules have installed their
|
||||
transitional window bridges. So the order is: classic scripts define the
|
||||
legacy globals → substrate modules evaluate → this module builds the shell
|
||||
and reparents the existing DOM → it calls `TS_APP.boot()` to start login +
|
||||
the Tier-1 cluster stream under the shell.
|
||||
|
||||
Re-point without rewiring: the cluster stream writes its connection status via
|
||||
getElementById("status-bar"); we MOVE that element (id preserved) into the
|
||||
@@ -19,8 +23,9 @@
|
||||
(pane code stays root-scoped).
|
||||
========================================================================== */
|
||||
|
||||
import { PaneManager, ShellPane } from "./pane.js";
|
||||
import { PaneManager, ShellPane, openPopupMenu } from "./pane.js";
|
||||
import { mountRail, mountManage, glyph } 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
|
||||
// pane lives at an absolute /static path that only the CONSOLE serves, so it is
|
||||
@@ -41,6 +46,7 @@ function buildShell(caps) {
|
||||
|
||||
// ----- Rail (the | of the L) -----
|
||||
const rail = make("aside", "rail");
|
||||
rail.id = "shell-rail"; // aria-controls target for the collapse toggle
|
||||
|
||||
const brand = make("div", "rail-brand");
|
||||
// The brand doubles as "go home" — a real <button> so it is keyboard-
|
||||
@@ -55,6 +61,13 @@ function buildShell(caps) {
|
||||
if (typeof window.showHome === "function") window.showHome();
|
||||
});
|
||||
brand.append(home);
|
||||
// Collapse toggle — shrinks the rail to a glyph-only strip (desktop only; on
|
||||
// mobile the rail is an off-canvas drawer and this button is hidden). Label,
|
||||
// glyph and aria state are kept in sync by the shell's setRailCollapsed.
|
||||
const collapseBtn = make("button", "rail-collapse");
|
||||
collapseBtn.type = "button";
|
||||
collapseBtn.setAttribute("aria-controls", "shell-rail");
|
||||
brand.append(collapseBtn);
|
||||
rail.append(brand);
|
||||
|
||||
const scroll = make("div", "rail-scroll");
|
||||
@@ -81,19 +94,42 @@ function buildShell(caps) {
|
||||
// ----- Content (the — of the L): tab bar + pane host -----
|
||||
const content = make("main", "content");
|
||||
const tabbar = make("div", "tabbar");
|
||||
const tail = make("div", "tabbar-right"); // right-floated tab-bar chrome (empty for now)
|
||||
tabbar.append(tail);
|
||||
// Drawer toggle — first tab-bar item, shown only at the mobile breakpoint
|
||||
// (the rail leaves the grid and overlays off-canvas there). State + focus
|
||||
// hand-off are wired by mountShell's setDrawer.
|
||||
const burger = make("button", "rail-burger", "☰");
|
||||
burger.type = "button";
|
||||
burger.setAttribute("aria-label", "Open navigation");
|
||||
burger.setAttribute("aria-controls", "shell-rail");
|
||||
burger.setAttribute("aria-expanded", "false");
|
||||
// The tab strip is its OWN element so PaneManager's role="tablist" wraps
|
||||
// ONLY the tabs — the burger and the [+] tail are non-tab focusables and
|
||||
// don't belong inside a tablist's accessibility tree. It is also the
|
||||
// horizontal scroller on mobile, so burger + [+] stay pinned while tabs
|
||||
// scroll.
|
||||
const tabstrip = make("div", "tabstrip");
|
||||
const tail = make("div", "tabbar-right"); // right-floated tab-bar chrome (the [+])
|
||||
tabbar.append(burger, tabstrip, tail);
|
||||
const panes = make("div", "panes");
|
||||
content.append(tabbar, panes);
|
||||
|
||||
app.append(rail, content);
|
||||
// Backdrop scrim for the mobile drawer — fixed overlay between content and
|
||||
// the off-canvas rail; decorative (the burger/Escape carry the semantics).
|
||||
const scrim = make("div", "rail-scrim");
|
||||
scrim.setAttribute("aria-hidden", "true");
|
||||
|
||||
app.append(rail, content, scrim);
|
||||
return {
|
||||
app,
|
||||
rail,
|
||||
collapseBtn,
|
||||
burger,
|
||||
scrim,
|
||||
scroll,
|
||||
connSlot,
|
||||
foot,
|
||||
tabbar,
|
||||
tabstrip,
|
||||
tail,
|
||||
panes,
|
||||
clusterSec,
|
||||
@@ -156,14 +192,29 @@ function stateForWs(wsId) {
|
||||
// makes a node-proxied session survive a reload: the node /events stream 404s
|
||||
// on a ws that isn't loaded on that node, so a freshly-rehydrated pane must
|
||||
// (re)open the session on a node first. Resolves to {nodeId} or {error}.
|
||||
// - Standalone (no cluster): every session is LOCAL → base "" (nodeId null),
|
||||
// no open round-trip.
|
||||
// - Standalone (no cluster): every session is LOCAL → base "" (nodeId null).
|
||||
// The first-activate path skips the /open round-trip (the resume flows
|
||||
// POST /open before opening the pane); the REVIVE path passes `openFirst`
|
||||
// because a closed / post-restart session 404s its /events until reopened.
|
||||
// - Console: the route + proxy + open work is the console's — delegate to the
|
||||
// TS_APP seam (origin-first POST /open with a rendezvous fallback). Without
|
||||
// the seam (unexpected on a cluster console) fall back to the open-time hint
|
||||
// or the live Tier-1 snapshot, accepting the pre-reload behaviour.
|
||||
function ensureInteractiveNode(caps, wsId, hint) {
|
||||
if (!caps.cluster) return Promise.resolve({ nodeId: null });
|
||||
// TS_APP seam (origin-first POST /open with a rendezvous fallback; it always
|
||||
// opens, so `openFirst` is implicit). Without the seam (unexpected on a
|
||||
// cluster console) fall back to the open-time hint or the live Tier-1
|
||||
// snapshot, accepting the pre-reload behaviour.
|
||||
function ensureInteractiveNode(caps, wsId, hint, openFirst) {
|
||||
if (!caps.cluster) {
|
||||
if (!openFirst) return Promise.resolve({ nodeId: null });
|
||||
return authFetch(
|
||||
"/v1/api/workstreams/" + encodeURIComponent(wsId) + "/open",
|
||||
{ method: "POST" },
|
||||
)
|
||||
.then((r) =>
|
||||
r.ok
|
||||
? { nodeId: null }
|
||||
: { error: "Could not reopen this session (" + r.status + ")." },
|
||||
)
|
||||
.catch(() => ({ error: "Could not reopen this session." }));
|
||||
}
|
||||
if (
|
||||
window.TS_APP &&
|
||||
typeof window.TS_APP.resolveInteractiveNode === "function"
|
||||
@@ -188,29 +239,83 @@ function paintConvTabs(pm) {
|
||||
}
|
||||
}
|
||||
|
||||
// POST a workstream verb against a pane's OWN transport base — the node proxy
|
||||
// for a console interactive pane, "" locally. The base-aware fallback lane for
|
||||
// deployments without the classic verb globals (see convTabMenu).
|
||||
function postWsVerb(base, wsId, verb, body) {
|
||||
return authFetch(
|
||||
base + "/v1/api/workstreams/" + encodeURIComponent(wsId) + "/" + verb,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body || {}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Tab-action menu items for a conversational pane — the three-verb close plus
|
||||
// the per-persona verbs. Pane-type-derived AND deployment-aware: a verb appears
|
||||
// only when its handler exists here, so the SAME shell yields the full menu in
|
||||
// the standalone (whose interactive verbs are classic globals in ui/static's
|
||||
// app.js) and a reduced menu in the console — the capability-derived-affordances
|
||||
// thesis applied to the tab menu. `opts`: titleVerbs (Refresh/Edit/Fork title),
|
||||
// deleteVerb (the destructive Delete), closeSession (stop the workstream itself).
|
||||
// the per-persona verbs. Pane-type-derived AND deployment-aware, in two lanes:
|
||||
// the classic verb GLOBALS where they exist (the standalone's ui/static app.js,
|
||||
// whose verbs also manage its local roster), else a base-aware fallback that
|
||||
// POSTs the verb straight to the pane's own transport base (`opts.base()` — the
|
||||
// console's node proxy). A node-verb is omitted only when no base is resolvable
|
||||
// yet (a never-activated pane with no node hint) — never aimed at the wrong
|
||||
// origin. `opts`: titleVerbs (Refresh/Edit/Fork title), deleteVerb (the
|
||||
// destructive Delete), closeSession (stop the workstream itself), base (a
|
||||
// () => string|null transport-base getter; omitted = "" local).
|
||||
function convTabMenu(pane, pm, wsId, opts) {
|
||||
opts = opts || {};
|
||||
const G = window;
|
||||
const items = [];
|
||||
const base = typeof opts.base === "function" ? opts.base() : "";
|
||||
const toast = (msg, kind) => {
|
||||
if (typeof G.showToast === "function") G.showToast(msg, kind);
|
||||
};
|
||||
if (opts.titleVerbs) {
|
||||
if (typeof G.refreshWorkstreamTitle === "function")
|
||||
items.push({
|
||||
label: "Refresh title",
|
||||
action: () => G.refreshWorkstreamTitle(wsId),
|
||||
});
|
||||
else if (base != null)
|
||||
items.push({
|
||||
label: "Refresh title",
|
||||
action: () =>
|
||||
postWsVerb(base, wsId, "refresh-title")
|
||||
.then((r) =>
|
||||
r.ok
|
||||
? toast("Title regeneration started…", "info")
|
||||
: toast("Failed to refresh title", "error"),
|
||||
)
|
||||
.catch(() => toast("Failed to refresh title", "error")),
|
||||
});
|
||||
if (typeof G.editWorkstreamTitle === "function")
|
||||
items.push({
|
||||
label: "Edit title",
|
||||
key: "Ctrl+Shift+E",
|
||||
action: () => G.editWorkstreamTitle(wsId),
|
||||
});
|
||||
else if (base != null)
|
||||
items.push({
|
||||
label: "Edit title",
|
||||
action: () => {
|
||||
const f = findWs(wsId, false);
|
||||
const cur = (f && (f.ws.name || f.ws.title)) || "";
|
||||
const next = window.prompt("Session title", cur);
|
||||
if (next == null) return; // cancelled
|
||||
const title = next.trim();
|
||||
if (!title || title === cur) return;
|
||||
postWsVerb(base, wsId, "title", { title })
|
||||
.then((r) =>
|
||||
r.ok
|
||||
? toast("Title updated", "success")
|
||||
: toast("Failed to set title", "error"),
|
||||
)
|
||||
.catch(() => toast("Failed to set title", "error"));
|
||||
},
|
||||
});
|
||||
// Fork stays global-only: it needs the standalone's seeded new-session
|
||||
// modal; the console has no interactive fork surface (yet).
|
||||
if (typeof G.forkWorkstream === "function")
|
||||
items.push({
|
||||
label: "Fork",
|
||||
@@ -218,12 +323,14 @@ function convTabMenu(pane, pm, wsId, opts) {
|
||||
action: () => G.forkWorkstream(wsId),
|
||||
});
|
||||
}
|
||||
if (typeof G.exportWorkstreamDownload === "function")
|
||||
// Export is base-aware everywhere (a proxied pane must export from its node,
|
||||
// not the console origin) — omitted while the node is unresolved.
|
||||
if (typeof G.exportWorkstreamDownload === "function" && base != null)
|
||||
items.push({
|
||||
label: "Export conversation",
|
||||
action: () => G.exportWorkstreamDownload(wsId),
|
||||
action: () => G.exportWorkstreamDownload(wsId, null, base),
|
||||
});
|
||||
items.push({ separator: true });
|
||||
if (items.length) items.push({ separator: true });
|
||||
// Close pane — drop the tab, leave the session running (PaneManager-level).
|
||||
items.push({
|
||||
label: "Close pane",
|
||||
@@ -233,14 +340,41 @@ function convTabMenu(pane, pm, wsId, opts) {
|
||||
// Close workstream — stop the session itself (distinct from closing the tab).
|
||||
if (opts.closeSession)
|
||||
items.push({ label: "Close workstream", action: opts.closeSession });
|
||||
// Delete — destroy + unsave (interactive standalone only; confirms itself).
|
||||
if (opts.deleteVerb && typeof G.confirmDeleteWorkstream === "function")
|
||||
items.push({
|
||||
label: "Delete",
|
||||
key: "Ctrl+Shift+X",
|
||||
cls: "destructive",
|
||||
action: () => G.confirmDeleteWorkstream(wsId),
|
||||
});
|
||||
// Delete — destroy + unsave. Standalone delegates to its modal-confirming
|
||||
// global; the console fallback confirms inline and deletes on the node.
|
||||
if (opts.deleteVerb) {
|
||||
if (typeof G.confirmDeleteWorkstream === "function")
|
||||
items.push({
|
||||
label: "Delete",
|
||||
key: "Ctrl+Shift+X",
|
||||
cls: "destructive",
|
||||
action: () => G.confirmDeleteWorkstream(wsId),
|
||||
});
|
||||
else if (base != null)
|
||||
items.push({
|
||||
label: "Delete",
|
||||
cls: "destructive",
|
||||
action: () => {
|
||||
if (!window.confirm("Delete this session? This cannot be undone."))
|
||||
return;
|
||||
postWsVerb(base, wsId, "delete")
|
||||
.then((r) => {
|
||||
// 404 = no row left to delete (already deleted elsewhere) — the
|
||||
// intent is satisfied either way; drop the tab.
|
||||
if (!r.ok && r.status !== 404) {
|
||||
toast("Failed to delete session", "error");
|
||||
return;
|
||||
}
|
||||
pm.close(pane.id);
|
||||
toast("Session deleted", "success");
|
||||
// The saved list holds the deleted row — refresh it if present.
|
||||
if (typeof G.loadSavedCoordinators === "function")
|
||||
G.loadSavedCoordinators();
|
||||
})
|
||||
.catch(() => toast("Failed to delete session", "error"));
|
||||
},
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
@@ -259,6 +393,46 @@ async function mountShell() {
|
||||
// (toast, modals, the login overlay appended later by auth.js) stay siblings.
|
||||
document.body.insertBefore(shell.app, document.body.firstChild);
|
||||
|
||||
// ----- Rail collapse (desktop) -----
|
||||
// Collapsed = a glyph-only strip: live state glyphs stay (sessions, cluster
|
||||
// counts), text labels hide, Manage becomes a single ⚙ row (rail.js). A UI
|
||||
// preference, so it persists across sessions in localStorage (same home as
|
||||
// the theme), unlike the per-tab working set in sessionStorage. CSS scopes
|
||||
// the collapsed layout to desktop; the mobile drawer always shows the full
|
||||
// rail, so the preference simply lies dormant there.
|
||||
const RAIL_COLLAPSE_KEY = "turnstone_interface.rail";
|
||||
const setRailCollapsed = (collapsed, persist) => {
|
||||
shell.app.classList.toggle("rail-collapsed", collapsed);
|
||||
shell.collapseBtn.textContent = collapsed ? "»" : "«"; // » / «
|
||||
const label = collapsed ? "Expand navigation" : "Collapse navigation";
|
||||
shell.collapseBtn.setAttribute("aria-label", label);
|
||||
shell.collapseBtn.title = label;
|
||||
shell.collapseBtn.setAttribute(
|
||||
"aria-expanded",
|
||||
collapsed ? "false" : "true",
|
||||
);
|
||||
if (persist) {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
RAIL_COLLAPSE_KEY,
|
||||
collapsed ? "collapsed" : "expanded",
|
||||
);
|
||||
} catch (e) {
|
||||
/* localStorage unavailable (private mode) — the toggle still works */
|
||||
}
|
||||
}
|
||||
};
|
||||
let railCollapsed = false;
|
||||
try {
|
||||
railCollapsed = localStorage.getItem(RAIL_COLLAPSE_KEY) === "collapsed";
|
||||
} catch (e) {
|
||||
/* unreadable preference — default expanded */
|
||||
}
|
||||
setRailCollapsed(railCollapsed, false);
|
||||
shell.collapseBtn.addEventListener("click", () =>
|
||||
setRailCollapsed(!shell.app.classList.contains("rail-collapsed"), true),
|
||||
);
|
||||
|
||||
// Relocate the connection indicator into the rail (id preserved → connectSSE
|
||||
// keeps writing to it; just styled as a rail line now).
|
||||
if (statusBarEl) {
|
||||
@@ -300,13 +474,42 @@ async function mountShell() {
|
||||
};
|
||||
|
||||
// ----- PaneManager: one new spine -----
|
||||
// It owns the tabstrip (role=tablist), NOT the whole tab bar: the burger and
|
||||
// the [+] tail live outside the strip so the tablist holds only tabs. No
|
||||
// tailEl — the strip has no non-tab chrome to anchor before.
|
||||
const pm = new PaneManager({
|
||||
tabbarEl: shell.tabbar,
|
||||
tabbarEl: shell.tabstrip,
|
||||
panesEl: shell.panes,
|
||||
tailEl: shell.tail,
|
||||
caps,
|
||||
});
|
||||
|
||||
// ----- Mobile drawer (the rail off-canvas below the breakpoint) -----
|
||||
// Open moves focus into the rail (its buttons are unreachable while
|
||||
// off-canvas — the closed drawer is visibility:hidden); close returns it to
|
||||
// the burger only for Escape, the keyboard path. Any pane activation closes
|
||||
// the drawer: a rail tap that opened/focused a pane has done its job, and
|
||||
// the stale-open drawer would cover the very pane it opened. Desktop is
|
||||
// untouched — the classes exist but the media query ignores them.
|
||||
const drawerOpen = () => shell.app.classList.contains("rail-open");
|
||||
const setDrawer = (open) => {
|
||||
if (open === drawerOpen()) return;
|
||||
shell.app.classList.toggle("rail-open", open);
|
||||
shell.burger.setAttribute("aria-expanded", open ? "true" : "false");
|
||||
if (open) {
|
||||
const first = shell.rail.querySelector("button");
|
||||
if (first) first.focus();
|
||||
}
|
||||
};
|
||||
shell.burger.addEventListener("click", () => setDrawer(!drawerOpen()));
|
||||
shell.scrim.addEventListener("mousedown", () => setDrawer(false));
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && drawerOpen()) {
|
||||
setDrawer(false);
|
||||
shell.burger.focus();
|
||||
}
|
||||
});
|
||||
pm.onActiveChange(() => setDrawer(false));
|
||||
|
||||
// [+] new-tab (step 7): a shortcut to the persona launcher. The Dashboard pane
|
||||
// hosts the unified coordinator/interactive launcher (a new session needs a task
|
||||
// prompt, so it composes there) — "new session" focuses it. showHome is exposed
|
||||
@@ -377,15 +580,66 @@ async function mountShell() {
|
||||
title: wsTitle(id),
|
||||
stateful: true, // tab shows live Tier-1 state (no static placeholder)
|
||||
});
|
||||
pane.tabMenu = () =>
|
||||
convTabMenu(pane, pm, id, {
|
||||
// The pane's CURRENT transport base for tab-menu verbs: the LIVE
|
||||
// controller's (exact), else the persisted node hint, else the live Tier-1
|
||||
// node; null = unresolved (node-verbs are omitted until the pane connects).
|
||||
// Standalone is always local (""). A DEAD controller is the exception: its
|
||||
// base — and the persisted hint that mirrors it — is stale once its node has
|
||||
// lost or RE-HOMED the ws, so trusting it would let the close/delete
|
||||
// 404-as-success lanes silently drop a tab whose session is alive on the
|
||||
// node it re-homed to. When dead we therefore mirror the revive path (the
|
||||
// live Tier-1 node leads), falling back to the stale base only if the ws is
|
||||
// gone cluster-wide, where its 404 correctly reads as "already closed".
|
||||
const menuBase = () => {
|
||||
if (pane._ctl && pane._ctl.isDead && pane._ctl.isDead()) {
|
||||
const live = caps.cluster ? nodeForWs(id) : null;
|
||||
return live ? "/node/" + encodeURIComponent(live) : pane._ctl.base;
|
||||
}
|
||||
if (pane._ctl && pane._ctl.base != null) return pane._ctl.base;
|
||||
if (pane.meta && pane.meta.nodeId)
|
||||
return "/node/" + encodeURIComponent(pane.meta.nodeId);
|
||||
if (!caps.cluster) return "";
|
||||
const live = nodeForWs(id);
|
||||
return live ? "/node/" + encodeURIComponent(live) : null;
|
||||
};
|
||||
pane.tabMenu = () => {
|
||||
// Close workstream: the standalone's roster-managing global where it
|
||||
// exists, else end the session on its own node (confirm-first, like the
|
||||
// coordinator's End session) and drop the tab. Hidden while the node is
|
||||
// unresolved — same omit-don't-misaim rule as the other node-verbs.
|
||||
const closeBase = menuBase();
|
||||
const closeSession =
|
||||
typeof window.closeWorkstream === "function"
|
||||
? () => window.closeWorkstream(id)
|
||||
: closeBase == null
|
||||
? null
|
||||
: () => {
|
||||
if (
|
||||
!window.confirm(
|
||||
"End this session? The server will terminate it.",
|
||||
)
|
||||
)
|
||||
return;
|
||||
const failToast = () => {
|
||||
if (typeof window.showToast === "function")
|
||||
window.showToast("Could not end session", "error");
|
||||
};
|
||||
postWsVerb(closeBase, id, "close")
|
||||
.then((r) => {
|
||||
// 404 = nothing left to stop (closed under us / node lost
|
||||
// it) — the user's intent is satisfied; drop the tab.
|
||||
if (r.ok || r.status === 404) pm.close(pane.id);
|
||||
else failToast();
|
||||
})
|
||||
.catch(failToast);
|
||||
};
|
||||
return convTabMenu(pane, pm, id, {
|
||||
titleVerbs: true,
|
||||
deleteVerb: true,
|
||||
closeSession:
|
||||
typeof window.closeWorkstream === "function"
|
||||
? () => window.closeWorkstream(id)
|
||||
: null,
|
||||
base: menuBase,
|
||||
closeSession: closeSession,
|
||||
});
|
||||
};
|
||||
// Persist the open-time node hint so a reload re-opens on the SAME node
|
||||
// (origin-first; avoids a re-route + duplicate load). Updated to the
|
||||
// resolved node after ensureInteractiveNode settles, below.
|
||||
@@ -413,6 +667,7 @@ async function mountShell() {
|
||||
pane._ctl = createInteractivePane(pane.bodyEl, id, {
|
||||
nodeId,
|
||||
onClose: () => pm.close(pane.id),
|
||||
onDead: showDeadBanner,
|
||||
});
|
||||
pane._ctl.connect();
|
||||
if (window.TS_LOGIN && pane._ctl.onLogin) {
|
||||
@@ -420,11 +675,49 @@ async function mountShell() {
|
||||
window.TS_LOGIN.subscribe(pane._ctl.onLogin);
|
||||
}
|
||||
};
|
||||
// Terminal dead session (the controller exhausted its reconnects, or Tier-1
|
||||
// said ws_closed): keep the conversation readable, but surface ONE
|
||||
// actionable affordance. Reviving is never automatic — a deliberately
|
||||
// closed session must not resurrect on a timer; the user (or an explicit
|
||||
// reopen gesture) decides.
|
||||
const showDeadBanner = () => {
|
||||
if (pane._closed || pane._deadBanner) return;
|
||||
const b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.className = "pane-status pane-status--retry pane-dead-banner";
|
||||
b.textContent = "Session disconnected — click to reconnect.";
|
||||
b.addEventListener("click", () => revive());
|
||||
pane.bodyEl.prepend(b);
|
||||
pane._deadBanner = b;
|
||||
};
|
||||
// Tear down the dead controller and re-run the resolve + connect path.
|
||||
// forceResolve: the session may need (re)opening on its node (POST /open)
|
||||
// or may have re-homed — never trust the dead controller's base. An
|
||||
// explicit fresh hint (a saved-row click carries the roster's node_id)
|
||||
// supersedes the stale persisted one.
|
||||
const revive = (freshNodeId) => {
|
||||
if (pane._closed || pane._resolving || !pane._ctl) return;
|
||||
if (pane._deadBanner) {
|
||||
pane._deadBanner.remove();
|
||||
pane._deadBanner = null;
|
||||
}
|
||||
if (window.TS_LOGIN && pane._ctl.onLogin)
|
||||
window.TS_LOGIN.unsubscribe(pane._ctl.onLogin);
|
||||
pane._ctl.destroy();
|
||||
pane._ctl = null;
|
||||
if (freshNodeId && (!pane.meta || pane.meta.nodeId !== freshNodeId)) {
|
||||
pane.meta = { nodeId: freshNodeId };
|
||||
pm.setPaneMeta(pane.id, pane.meta);
|
||||
}
|
||||
pane._statusEl = make("div", "pane-status", "Reconnecting…");
|
||||
pane.bodyEl.append(pane._statusEl);
|
||||
beginConnect(true);
|
||||
};
|
||||
// Errored resolve (capacity / no node free): show it in the status line and
|
||||
// offer a one-click retry — re-clicking the tab won't re-fire onActivate
|
||||
// (PaneManager fires it only on a pane CHANGE), so without this a transient
|
||||
// failure would strand the pane until the user closed + reopened it.
|
||||
const showResolveError = (msg) => {
|
||||
const showResolveError = (msg, forceResolve) => {
|
||||
const el = pane._statusEl;
|
||||
if (!el) return;
|
||||
el.className = "pane-status pane-status--retry msg error";
|
||||
@@ -436,7 +729,7 @@ async function mountShell() {
|
||||
el.title = "";
|
||||
el.onclick = null;
|
||||
el.textContent = "Connecting…";
|
||||
beginConnect();
|
||||
beginConnect(forceResolve);
|
||||
};
|
||||
};
|
||||
// First-activate connect. A LIVE session (Tier-1 already names its node, so
|
||||
@@ -444,19 +737,26 @@ async function mountShell() {
|
||||
// hot rail / active-row / just-created path. Standalone runs locally. Only
|
||||
// the dormant / reload case (the snapshot has no node for this ws) resolves
|
||||
// the node + (re)opens the session, whose /events would otherwise 404.
|
||||
const beginConnect = () => {
|
||||
// `forceResolve` (the revive path) skips BOTH fast paths so the resolve
|
||||
// POSTs /open — the give-up fired because /events 404'd, so the session
|
||||
// needs (re)loading even when a stale Tier-1 row still names a node. The
|
||||
// live node (when one exists) stays the HINT, so the origin-first /open
|
||||
// reuses a genuinely-live session in place rather than loading a second
|
||||
// copy on the old meta node.
|
||||
const beginConnect = (forceResolve) => {
|
||||
const liveNode = caps.cluster ? nodeForWs(id) : null;
|
||||
if (liveNode || !caps.cluster) {
|
||||
if (!forceResolve && (liveNode || !caps.cluster)) {
|
||||
buildController(liveNode || null);
|
||||
return;
|
||||
}
|
||||
pane._resolving = true;
|
||||
const hint = (pane.meta && pane.meta.nodeId) || (extra && extra.nodeId);
|
||||
ensureInteractiveNode(caps, id, hint).then((res) => {
|
||||
const hint =
|
||||
liveNode || (pane.meta && pane.meta.nodeId) || (extra && extra.nodeId);
|
||||
ensureInteractiveNode(caps, id, hint, forceResolve).then((res) => {
|
||||
pane._resolving = false;
|
||||
if (pane._closed) return; // closed mid-resolve — don't build into a detached body
|
||||
if (!res || res.error) {
|
||||
showResolveError(res && res.error);
|
||||
showResolveError(res && res.error, forceResolve);
|
||||
return;
|
||||
}
|
||||
buildController(res.nodeId);
|
||||
@@ -465,12 +765,24 @@ async function mountShell() {
|
||||
pane.onActivate = function () {
|
||||
pm.setTabGlyph(pane.id, glyph(stateForWs(id))); // live Tier-1 state glyph
|
||||
if (this._ctl) {
|
||||
if (this._ctl.isDead && this._ctl.isDead()) {
|
||||
showDeadBanner(); // visible terminal state; reviving is the user's call
|
||||
return;
|
||||
}
|
||||
this._ctl.connect(); // built — idempotent re-mark focus
|
||||
return;
|
||||
}
|
||||
if (this._resolving) return; // first-activate resolve already in flight
|
||||
beginConnect();
|
||||
};
|
||||
// Explicit re-open (saved-list resume, rail row, child link) targeted this
|
||||
// already-open pane. A healthy pane needs nothing (activate re-marked
|
||||
// focus); a DEAD one revives — this is the "resume with a pre-existing tab"
|
||||
// path, which previously focused the dead pane and reconnected nothing.
|
||||
pane.onReopen = function (reExtra) {
|
||||
if (this._ctl && this._ctl.isDead && this._ctl.isDead())
|
||||
revive(reExtra && reExtra.nodeId);
|
||||
};
|
||||
pane.onDeactivate = function () {
|
||||
if (this._ctl && this._ctl.deactivate) this._ctl.deactivate();
|
||||
};
|
||||
@@ -525,6 +837,14 @@ async function mountShell() {
|
||||
}
|
||||
}
|
||||
};
|
||||
// Explicit re-open (saved-list resume with this pane already open): the
|
||||
// resume already POSTed /open, so a coordinator whose stream went dead
|
||||
// (session was closed, console restarted) just needs a fresh connect —
|
||||
// reconnect() no-ops on a healthy OPEN stream.
|
||||
pane.onReopen = function () {
|
||||
if (this._connected && this._ctl && this._ctl.reconnect)
|
||||
this._ctl.reconnect();
|
||||
};
|
||||
pane.onClose = function () {
|
||||
if (this._ctl) {
|
||||
if (window.TS_LOGIN && this._ctl.onLogin)
|
||||
@@ -553,7 +873,14 @@ async function mountShell() {
|
||||
}
|
||||
}
|
||||
|
||||
window.TS_SHELL = { panes: pm, caps };
|
||||
// Tier-1 lifecycle → pane signal. The console's ws_closed handler calls this
|
||||
// so an open pane on that session stops its reconnect loop NOW (instead of
|
||||
// 404-polling a session that is gone) and shows the reconnect affordance.
|
||||
const notifySessionClosed = (wsId) => {
|
||||
const p = pm.getPane("interactive", wsId);
|
||||
if (p && p._ctl && p._ctl.markDead) p._ctl.markDead();
|
||||
};
|
||||
window.TS_SHELL = { panes: pm, caps, notifySessionClosed };
|
||||
|
||||
// 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
|
||||
@@ -628,80 +955,41 @@ async function mountShell() {
|
||||
}
|
||||
|
||||
// --- Footer user menu (Log out lives here) ---------------------------------
|
||||
// A small popup anchored to the rail-footer user chip. Reuses the .tab-menu
|
||||
// popup chrome; the one item clicks the hidden #logout-btn so auth.js stays the
|
||||
// single owner of logout (incl. its in-flight-refresh race guards).
|
||||
let _userMenuCleanup = null;
|
||||
|
||||
function closeUserMenu() {
|
||||
if (_userMenuCleanup) _userMenuCleanup();
|
||||
}
|
||||
// A small popup anchored to the rail-footer user chip, riding the shared
|
||||
// openPopupMenu chrome (pane.js — same vocabulary + keyboard behaviour as the
|
||||
// tab-action dropdown). The one item clicks the hidden #logout-btn so auth.js
|
||||
// stays the single owner of logout (incl. its in-flight-refresh race guards).
|
||||
let _userMenu = null;
|
||||
|
||||
function toggleUserMenu(chip) {
|
||||
if (_userMenuCleanup) {
|
||||
closeUserMenu();
|
||||
if (_userMenu) {
|
||||
_userMenu.close();
|
||||
return;
|
||||
}
|
||||
const menu = document.createElement("div");
|
||||
menu.className = "tab-menu user-menu";
|
||||
menu.setAttribute("role", "menu");
|
||||
menu.setAttribute("aria-label", "Account");
|
||||
const item = document.createElement("button");
|
||||
item.type = "button";
|
||||
item.className = "tab-menu-item destructive";
|
||||
item.setAttribute("role", "menuitem");
|
||||
const label = document.createElement("span");
|
||||
label.className = "tab-menu-label";
|
||||
label.textContent = "Log out";
|
||||
item.append(label);
|
||||
item.addEventListener("click", () => {
|
||||
closeUserMenu();
|
||||
const lb = document.getElementById("logout-btn");
|
||||
if (lb) lb.click();
|
||||
});
|
||||
menu.append(item);
|
||||
document.body.append(menu);
|
||||
|
||||
// Fixed-positioned, popping UP from the chip (the footer sits at the viewport
|
||||
// bottom); flip down only if there is no room above.
|
||||
const ar = chip.getBoundingClientRect();
|
||||
const mr = menu.getBoundingClientRect();
|
||||
let x = ar.left;
|
||||
if (x + mr.width > window.innerWidth) x = window.innerWidth - mr.width - 4;
|
||||
if (x < 4) x = 4;
|
||||
let y = ar.top - mr.height - 4;
|
||||
if (y < 4) y = ar.bottom + 4;
|
||||
menu.style.left = x + "px";
|
||||
menu.style.top = y + "px";
|
||||
chip.setAttribute("aria-expanded", "true");
|
||||
|
||||
const onDown = (e) => {
|
||||
if (!menu.contains(e.target) && !chip.contains(e.target)) closeUserMenu();
|
||||
};
|
||||
const onKey = (e) => {
|
||||
if (e.key === "Escape") {
|
||||
closeUserMenu();
|
||||
chip.focus();
|
||||
}
|
||||
};
|
||||
_userMenuCleanup = () => {
|
||||
document.removeEventListener("mousedown", onDown);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
menu.remove();
|
||||
chip.setAttribute("aria-expanded", "false");
|
||||
_userMenuCleanup = null;
|
||||
};
|
||||
// Defer the listener attach so the click that opened the menu does not
|
||||
// immediately close it.
|
||||
setTimeout(() => {
|
||||
// Bail if the menu was already closed before this deferred attach ran:
|
||||
// closeUserMenu() nulls _userMenuCleanup, so attaching now would leave the
|
||||
// listeners with no cleanup ref to remove them (a permanent leak).
|
||||
if (!_userMenuCleanup) return;
|
||||
document.addEventListener("mousedown", onDown);
|
||||
document.addEventListener("keydown", onKey);
|
||||
}, 0);
|
||||
item.focus();
|
||||
_userMenu = openPopupMenu(
|
||||
chip,
|
||||
[
|
||||
{
|
||||
label: "Log out",
|
||||
cls: "destructive",
|
||||
action: () => {
|
||||
const lb = document.getElementById("logout-btn");
|
||||
if (lb) lb.click();
|
||||
},
|
||||
},
|
||||
],
|
||||
{
|
||||
cls: "user-menu",
|
||||
label: "Account",
|
||||
prefer: "up", // the footer chip sits at the viewport bottom
|
||||
align: "start",
|
||||
expandEl: chip,
|
||||
returnFocusEl: chip,
|
||||
onClose: () => {
|
||||
_userMenu = null;
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function displayNameFor(caps) {
|
||||
|
||||
@@ -14,73 +14,76 @@
|
||||
* and effort-suffix rules. Each surface owns its own DOM (different
|
||||
* element ids); the formatter takes the three cell spans + the
|
||||
* status-bar root + the SSE event.
|
||||
*
|
||||
* ES module; window bridge below for the still-classic consumers.
|
||||
*/
|
||||
(function (root) {
|
||||
"use strict";
|
||||
// Context-percent thresholds for the warn / danger paint. Mirrored
|
||||
// by the .ws-sb-warn / .ws-sb-danger CSS toggles in chat.css.
|
||||
var CTX_WARN_PCT = 80;
|
||||
var CTX_DANGER_PCT = 95;
|
||||
var WARN_PREFIX = "▲ "; // ▲
|
||||
var DANGER_PREFIX = "⚠ "; // ⚠
|
||||
// Effort values that should NOT surface as a suffix on the tokens
|
||||
// cell. "medium" is the implicit default; "" / null means the
|
||||
// model doesn't expose a reasoning_effort knob.
|
||||
var SILENT_EFFORTS = { medium: 1, "": 1 };
|
||||
|
||||
// Context-percent thresholds for the warn / danger paint. Mirrored
|
||||
// by the .ws-sb-warn / .ws-sb-danger CSS toggles in chat.css.
|
||||
var CTX_WARN_PCT = 80;
|
||||
var CTX_DANGER_PCT = 95;
|
||||
var WARN_PREFIX = "▲ "; // ▲
|
||||
var DANGER_PREFIX = "⚠ "; // ⚠
|
||||
// Effort values that should NOT surface as a suffix on the tokens
|
||||
// cell. "medium" is the implicit default; "" / null means the
|
||||
// model doesn't expose a reasoning_effort knob.
|
||||
var SILENT_EFFORTS = { medium: 1, "": 1 };
|
||||
/**
|
||||
* Repaint the three-cell status bar from an on_status SSE event.
|
||||
*
|
||||
* @param {Object} els — { rootEl, tokensEl, toolsEl, turnsEl }
|
||||
* @param {Object} evt — on_status payload (total_tokens, context_window,
|
||||
* pct, effort, tool_calls_this_turn, turn_count).
|
||||
*/
|
||||
function paintStatusBar(els, evt) {
|
||||
if (!els || !evt) return;
|
||||
|
||||
/**
|
||||
* Repaint the three-cell status bar from an on_status SSE event.
|
||||
*
|
||||
* @param {Object} els — { rootEl, tokensEl, toolsEl, turnsEl }
|
||||
* @param {Object} evt — on_status payload (total_tokens, context_window,
|
||||
* pct, effort, tool_calls_this_turn, turn_count).
|
||||
*/
|
||||
function paintStatusBar(els, evt) {
|
||||
if (!els || !evt) return;
|
||||
|
||||
var totalTokens = evt.total_tokens || 0;
|
||||
var contextWindow = evt.context_window || 0;
|
||||
var pct = evt.pct || 0;
|
||||
var tokenText =
|
||||
totalTokens.toLocaleString() +
|
||||
" / " +
|
||||
(contextWindow ? contextWindow.toLocaleString() : "—") +
|
||||
(contextWindow ? " (" + pct + "%)" : "");
|
||||
var effort = evt.effort || "";
|
||||
if (effort && !(effort in SILENT_EFFORTS)) {
|
||||
tokenText += " · " + effort;
|
||||
}
|
||||
if (pct >= CTX_DANGER_PCT) tokenText = DANGER_PREFIX + tokenText;
|
||||
else if (pct >= CTX_WARN_PCT) tokenText = WARN_PREFIX + tokenText;
|
||||
if (els.tokensEl) els.tokensEl.textContent = tokenText;
|
||||
|
||||
var tc = evt.tool_calls_this_turn || 0;
|
||||
if (els.toolsEl) {
|
||||
els.toolsEl.textContent = tc + " tool" + (tc !== 1 ? "s" : "");
|
||||
}
|
||||
var turns = evt.turn_count || 0;
|
||||
if (els.turnsEl) els.turnsEl.textContent = "turn " + turns;
|
||||
|
||||
if (els.rootEl) {
|
||||
els.rootEl.classList.toggle("ws-sb-warn", pct >= CTX_WARN_PCT);
|
||||
els.rootEl.classList.toggle("ws-sb-danger", pct >= CTX_DANGER_PCT);
|
||||
}
|
||||
var totalTokens = evt.total_tokens || 0;
|
||||
var contextWindow = evt.context_window || 0;
|
||||
var pct = evt.pct || 0;
|
||||
var tokenText =
|
||||
totalTokens.toLocaleString() +
|
||||
" / " +
|
||||
(contextWindow ? contextWindow.toLocaleString() : "—") +
|
||||
(contextWindow ? " (" + pct + "%)" : "");
|
||||
var effort = evt.effort || "";
|
||||
if (effort && !(effort in SILENT_EFFORTS)) {
|
||||
tokenText += " · " + effort;
|
||||
}
|
||||
if (pct >= CTX_DANGER_PCT) tokenText = DANGER_PREFIX + tokenText;
|
||||
else if (pct >= CTX_WARN_PCT) tokenText = WARN_PREFIX + tokenText;
|
||||
if (els.tokensEl) els.tokensEl.textContent = tokenText;
|
||||
|
||||
/**
|
||||
* Reset the tokens cell to its placeholder text. Called by the
|
||||
* coord dashboard on SSE reconnect when no prior status event has
|
||||
* been seen, so the transient "Reconnecting…" copy doesn't stick.
|
||||
*/
|
||||
function resetTokensPlaceholder(tokensEl) {
|
||||
if (tokensEl) tokensEl.textContent = "0 / —";
|
||||
var tc = evt.tool_calls_this_turn || 0;
|
||||
if (els.toolsEl) {
|
||||
els.toolsEl.textContent = tc + " tool" + (tc !== 1 ? "s" : "");
|
||||
}
|
||||
var turns = evt.turn_count || 0;
|
||||
if (els.turnsEl) els.turnsEl.textContent = "turn " + turns;
|
||||
|
||||
root.StatusBar = {
|
||||
paint: paintStatusBar,
|
||||
resetTokensPlaceholder: resetTokensPlaceholder,
|
||||
CTX_WARN_PCT: CTX_WARN_PCT,
|
||||
CTX_DANGER_PCT: CTX_DANGER_PCT,
|
||||
};
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
if (els.rootEl) {
|
||||
els.rootEl.classList.toggle("ws-sb-warn", pct >= CTX_WARN_PCT);
|
||||
els.rootEl.classList.toggle("ws-sb-danger", pct >= CTX_DANGER_PCT);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the tokens cell to its placeholder text. Called by the
|
||||
* coord dashboard on SSE reconnect when no prior status event has
|
||||
* been seen, so the transient "Reconnecting…" copy doesn't stick.
|
||||
*/
|
||||
function resetTokensPlaceholder(tokensEl) {
|
||||
if (tokensEl) tokensEl.textContent = "0 / —";
|
||||
}
|
||||
|
||||
export const StatusBar = {
|
||||
paint: paintStatusBar,
|
||||
resetTokensPlaceholder: resetTokensPlaceholder,
|
||||
CTX_WARN_PCT: CTX_WARN_PCT,
|
||||
CTX_DANGER_PCT: CTX_DANGER_PCT,
|
||||
};
|
||||
|
||||
// --- Legacy window bridge ---------------------------------------------------
|
||||
// Still-classic consumers reach this as a global at event/boot time (after
|
||||
// this deferred module evaluated). New module code imports instead.
|
||||
window.StatusBar = StatusBar;
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
/* Shared toast notification — turnstone design system
|
||||
Configure timeout via window.TURNSTONE_TOAST_TIMEOUT (default 3000ms) */
|
||||
Configure timeout via window.TURNSTONE_TOAST_TIMEOUT (default 3000ms).
|
||||
ES module, dependency-free; window bridge below for classic consumers. */
|
||||
|
||||
var _toastQueue = [];
|
||||
var _toastTimer = null;
|
||||
var _toastShowing = false;
|
||||
var _TOAST_TIMEOUT = window.TURNSTONE_TOAST_TIMEOUT || 3000;
|
||||
const _toastQueue = [];
|
||||
let _toastTimer = null;
|
||||
let _toastShowing = false;
|
||||
const _TOAST_TIMEOUT = window.TURNSTONE_TOAST_TIMEOUT || 3000;
|
||||
|
||||
function showToast(message, type) {
|
||||
var el = document.getElementById("toast");
|
||||
export function showToast(message, type) {
|
||||
const el = document.getElementById("toast");
|
||||
if (!el) return;
|
||||
if (_toastShowing) {
|
||||
_toastQueue.push({ message: message, type: type });
|
||||
@@ -20,18 +21,45 @@ 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) {
|
||||
setTimeout(function () {
|
||||
var item = _toastQueue.shift();
|
||||
const item = _toastQueue.shift();
|
||||
_displayToast(el, item.message, item.type);
|
||||
}, 300);
|
||||
}
|
||||
}, _TOAST_TIMEOUT);
|
||||
}
|
||||
|
||||
// --- Legacy window bridge ---------------------------------------------------
|
||||
// Still-classic consumers reach this as a global at event/boot time (after
|
||||
// this deferred module evaluated). New module code imports instead.
|
||||
window.showToast = showToast;
|
||||
|
||||
@@ -1,18 +1,28 @@
|
||||
/* Shared utility functions — turnstone design system */
|
||||
/* Shared utility functions — turnstone design system
|
||||
|
||||
function escapeHtml(text) {
|
||||
ES module at the BOTTOM of the shared module graph: imports nothing, so
|
||||
renderer.js / auth.js / kb.js / cards.js can import from here without
|
||||
cycles. The two helpers that call upward (setMarkdown → renderer,
|
||||
exportWorkstreamDownload → toast/auth) late-bind through window at CALL
|
||||
time instead — importing them here would close an import cycle.
|
||||
|
||||
The window bridge at the bottom keeps the still-classic consumers
|
||||
(console app.js / admin.js / governance.js, ui app.js, inline onclick=)
|
||||
working; modules should import instead. */
|
||||
|
||||
export function escapeHtml(text) {
|
||||
const el = document.createElement("span");
|
||||
el.textContent = text;
|
||||
return el.innerHTML.replace(/'/g, "'").replace(/"/g, """);
|
||||
}
|
||||
|
||||
function formatTokens(n) {
|
||||
export function formatTokens(n) {
|
||||
if (n >= 1000000) return (n / 1000000).toFixed(1) + "M";
|
||||
if (n >= 1000) return (n / 1000).toFixed(1) + "k";
|
||||
return String(n || 0);
|
||||
}
|
||||
|
||||
function ctxClass(ratio) {
|
||||
export function ctxClass(ratio) {
|
||||
if (ratio <= 0) return "ctx-idle";
|
||||
const pct = ratio * 100;
|
||||
if (pct < 30) return "ctx-low";
|
||||
@@ -21,7 +31,7 @@ function ctxClass(ratio) {
|
||||
return "ctx-danger";
|
||||
}
|
||||
|
||||
function formatUptime(seconds) {
|
||||
export function formatUptime(seconds) {
|
||||
if (!seconds) return "";
|
||||
if (seconds < 60) return seconds + "s";
|
||||
const min = Math.floor(seconds / 60);
|
||||
@@ -30,7 +40,7 @@ function formatUptime(seconds) {
|
||||
return hr + "h " + (min % 60) + "m";
|
||||
}
|
||||
|
||||
function formatCount(n) {
|
||||
export function formatCount(n) {
|
||||
if (n >= 1000) return (n / 1000).toFixed(1) + "k";
|
||||
return String(n);
|
||||
}
|
||||
@@ -51,14 +61,14 @@ const OPERATOR_SOURCE_LABELS = {
|
||||
tool_error: "tool error",
|
||||
skill_hint: "skill hint",
|
||||
};
|
||||
function operatorSourceLabel(source) {
|
||||
export function operatorSourceLabel(source) {
|
||||
return OPERATOR_SOURCE_LABELS[source] || source || "operator";
|
||||
}
|
||||
|
||||
// Naive ISO-8601 → "Nm ago" / "Nh ago" / "Nd ago" / locale date.
|
||||
// Tolerates space-as-separator (SQLite default) and missing TZ marker
|
||||
// (assumes UTC, matching the storage layer's stamp).
|
||||
function formatRelativeTime(iso) {
|
||||
export function formatRelativeTime(iso) {
|
||||
if (!iso) return "";
|
||||
let s = String(iso).replace(" ", "T");
|
||||
if (!s.endsWith("Z") && !s.includes("+")) s += "Z";
|
||||
@@ -83,7 +93,7 @@ function formatRelativeTime(iso) {
|
||||
// actually appear in our id formats — hex ws_ids, alphanumeric
|
||||
// node_ids — and escapes the characters a CSS attribute selector
|
||||
// treats specially.
|
||||
function cssEscape(s) {
|
||||
export function cssEscape(s) {
|
||||
const str = String(s == null ? "" : s);
|
||||
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
|
||||
return CSS.escape(str);
|
||||
@@ -98,7 +108,7 @@ function cssEscape(s) {
|
||||
// a DocumentFragment lets callers use either append() or
|
||||
// replaceChildren() depending on whether the button is fresh or
|
||||
// rebuilt in place.
|
||||
function makeKeyLabel(hint, label) {
|
||||
export function makeKeyLabel(hint, label) {
|
||||
const span = document.createElement("span");
|
||||
span.className = "key";
|
||||
span.textContent = hint;
|
||||
@@ -111,7 +121,7 @@ function makeKeyLabel(hint, label) {
|
||||
// "Loading…", "Failed to load", and "No active workstreams" states
|
||||
// across the dashboard surfaces. Callers typically pass the result to
|
||||
// el.replaceChildren(...) so the empty card replaces existing content.
|
||||
function makeEmptyState(text) {
|
||||
export function makeEmptyState(text) {
|
||||
const div = document.createElement("div");
|
||||
div.className = "dashboard-empty";
|
||||
div.textContent = text;
|
||||
@@ -125,7 +135,7 @@ function makeEmptyState(text) {
|
||||
// interpolation) — DOMParser will faithfully parse whatever it is
|
||||
// given. The DOMParser path keeps the unsafe sink off the call site
|
||||
// without requiring every caller to construct DOM elements by hand.
|
||||
function setSafeHtml(el, html) {
|
||||
export function setSafeHtml(el, html) {
|
||||
const parsed = new DOMParser().parseFromString(html, "text/html");
|
||||
el.replaceChildren(...Array.from(parsed.body.childNodes));
|
||||
}
|
||||
@@ -137,22 +147,26 @@ function setSafeHtml(el, html) {
|
||||
// as renderer.js is trusted. postRenderMarkdown finishes the job —
|
||||
// hljs highlighting + mermaid SVG rendering for any code blocks the
|
||||
// markdown emitted.
|
||||
function setMarkdown(el, content) {
|
||||
setSafeHtml(el, renderMarkdown(content));
|
||||
postRenderMarkdown(el);
|
||||
export function setMarkdown(el, content) {
|
||||
setSafeHtml(el, window.renderMarkdown(content));
|
||||
window.postRenderMarkdown(el);
|
||||
}
|
||||
|
||||
// Download a workstream's conversation as OpenAI-shaped JSON. Hits
|
||||
// GET /v1/api/workstreams/{ws_id}/export, which streams a
|
||||
// GET {base}/v1/api/workstreams/{ws_id}/export, which streams a
|
||||
// ``{"messages":[...]}`` body with a Content-Disposition attachment
|
||||
// filename. Shared by the interactive appbar (app.js) and the
|
||||
// coordinator appbar (coordinator.js) so both export buttons behave
|
||||
// identically. authFetch already handles the 401 (shows login) and
|
||||
// identically. ``base`` is the session's transport prefix — "" for a
|
||||
// local / console-homed session (the default), "/node/{id}" when the
|
||||
// console proxies a node-hosted interactive session (the export must
|
||||
// come from the node that owns the conversation, not the console).
|
||||
// authFetch already handles the 401 (shows login) and
|
||||
// 429 (retry) paths and returns the raw Response, so we read .blob()
|
||||
// directly and synthesise an anchor click to trigger the browser save.
|
||||
async function exportWorkstreamDownload(wsId, btn) {
|
||||
export async function exportWorkstreamDownload(wsId, btn, base) {
|
||||
if (!wsId) {
|
||||
showToast("No conversation to export", "error");
|
||||
window.showToast("No conversation to export", "error");
|
||||
return;
|
||||
}
|
||||
// Re-entrancy guard: a double-click (or Enter+Enter) must not fire two
|
||||
@@ -166,17 +180,21 @@ async function exportWorkstreamDownload(wsId, btn) {
|
||||
btn.setAttribute("aria-busy", "true");
|
||||
}
|
||||
try {
|
||||
const url = "/v1/api/workstreams/" + encodeURIComponent(wsId) + "/export";
|
||||
const url =
|
||||
(base || "") +
|
||||
"/v1/api/workstreams/" +
|
||||
encodeURIComponent(wsId) +
|
||||
"/export";
|
||||
let r;
|
||||
try {
|
||||
r = await authFetch(url);
|
||||
r = await window.authFetch(url);
|
||||
} catch (e) {
|
||||
// authFetch throws Error("auth") on 401 after showing the login
|
||||
// modal — nothing more to do here.
|
||||
return;
|
||||
}
|
||||
if (!r || !r.ok) {
|
||||
showToast("Export failed", "error");
|
||||
window.showToast("Export failed", "error");
|
||||
return;
|
||||
}
|
||||
let filename = wsId + ".json";
|
||||
@@ -194,7 +212,7 @@ async function exportWorkstreamDownload(wsId, btn) {
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(objUrl);
|
||||
showToast("Exported " + filename);
|
||||
window.showToast("Exported " + filename);
|
||||
} finally {
|
||||
exportWorkstreamDownload._busy = false;
|
||||
if (btn) {
|
||||
@@ -203,3 +221,24 @@ async function exportWorkstreamDownload(wsId, btn) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Legacy window bridge ---------------------------------------------------
|
||||
// Classic consumers (console app.js / admin.js / governance.js, ui app.js,
|
||||
// inline onclick=) still reach these as globals; they only do so at event /
|
||||
// boot time, well after this deferred module has evaluated. New module code
|
||||
// imports instead. Drop entries as the classic bundles migrate.
|
||||
Object.assign(window, {
|
||||
escapeHtml,
|
||||
formatTokens,
|
||||
ctxClass,
|
||||
formatUptime,
|
||||
formatCount,
|
||||
operatorSourceLabel,
|
||||
formatRelativeTime,
|
||||
cssEscape,
|
||||
makeKeyLabel,
|
||||
makeEmptyState,
|
||||
setSafeHtml,
|
||||
setMarkdown,
|
||||
exportWorkstreamDownload,
|
||||
});
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+175
-325
@@ -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,67 +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;
|
||||
}
|
||||
document.getElementById("new-tab-btn").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();
|
||||
@@ -503,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();
|
||||
@@ -529,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) {
|
||||
@@ -544,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",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -789,43 +740,50 @@ function updateDashFooter(agg) {
|
||||
// the per-app inputs are the column spec, the DOM refs, and the path-keyed
|
||||
// delete request. Coordinators (console/static) use the same helper with a
|
||||
// CHILDREN column instead of MSGS.
|
||||
const WS_COLUMNS = [
|
||||
SavedColumns.name(),
|
||||
SavedColumns.model(),
|
||||
SavedColumns.count("message_count", "MSGS"),
|
||||
SavedColumns.ctx(),
|
||||
SavedColumns.last(),
|
||||
SavedColumns.id(),
|
||||
];
|
||||
const _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" },
|
||||
};
|
||||
let _wsTable = null;
|
||||
|
||||
// Built at boot, not parse: the saved-table substrate (/shared/cards.js) is a
|
||||
// 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);
|
||||
},
|
||||
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
|
||||
// binds to (`onclick="startWsDeleteMode()"` etc.) and forward to the shared
|
||||
@@ -842,12 +800,6 @@ function toggleSelectAll() {
|
||||
function confirmWsDeleteSelection() {
|
||||
_wsTable.controller.confirmSelection();
|
||||
}
|
||||
function cancelWsDelete() {
|
||||
_wsTable.controller.closeModal();
|
||||
}
|
||||
function confirmWsDelete() {
|
||||
_wsTable.controller.confirm();
|
||||
}
|
||||
|
||||
// --- Workstream title management ---
|
||||
|
||||
@@ -874,7 +826,7 @@ function refreshWorkstreamTitle(optWsId) {
|
||||
});
|
||||
}
|
||||
|
||||
let _editTitleTrap = null;
|
||||
let _editTitleWsId = null;
|
||||
|
||||
function editWorkstreamTitle(optWsId) {
|
||||
const wsId = optWsId || getCurrentWsId();
|
||||
@@ -882,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) {
|
||||
@@ -940,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" },
|
||||
@@ -950,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(
|
||||
@@ -962,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");
|
||||
});
|
||||
}
|
||||
@@ -969,7 +913,6 @@ function submitEditTitle() {
|
||||
// --- Workstream deletion ---
|
||||
|
||||
let _pendingDeleteWsId = null;
|
||||
let _deleteWsTrap = null;
|
||||
|
||||
function confirmDeleteWorkstream(optWsId) {
|
||||
const wsId = optWsId || getCurrentWsId();
|
||||
@@ -979,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";
|
||||
|
||||
@@ -1035,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];
|
||||
@@ -1047,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");
|
||||
});
|
||||
}
|
||||
@@ -1879,9 +1807,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),
|
||||
@@ -1892,54 +1817,18 @@ 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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Settings menu (gear icon dropdown — MCP connections + Logout)
|
||||
// MCP connections panel (Manage -> Connections via the rail; the old gear
|
||||
// dropdown that fronted it was retired with the split-pane tab bar)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Reuses the .ws-tab-dropdown shell for visual + behavioural consistency
|
||||
// with the workstream tab dropdown and the console proxy's node-picker.
|
||||
// Keyboard handling matches the proxy node-picker (the APG-correct
|
||||
// reference): Tab closes the menu WITHOUT preventDefault so focus
|
||||
// moves naturally to the next focusable; Escape closes + refocuses
|
||||
// the trigger. showTabDropdown collapses Tab and Escape into a
|
||||
// single preventDefault branch — that's a pre-existing divergence,
|
||||
// tracked as a follow-up to align showTabDropdown to APG. ArrowUp
|
||||
// uses an `idx <= 0` guard (not modulo) so the no-focus case wraps
|
||||
// to the last item rather than the second-to-last — same shape as
|
||||
// showTabDropdown and the proxy node-picker.
|
||||
|
||||
let _settingsMenu = null;
|
||||
let _settingsMenuCloseHandler = null;
|
||||
// Cached at open time so closeSettingsMenu can reset ARIA without
|
||||
// re-querying by id, and so the menu-item click path can refocus
|
||||
// the trigger BEFORE close — that way openSettingsPanel captures
|
||||
// the gear (not <body>) as _settingsReturnFocus.
|
||||
let _settingsMenuTrigger = null;
|
||||
|
||||
function loadMcpConnections() {
|
||||
const loadingEl = document.getElementById("settings-mcp-loading");
|
||||
@@ -2050,54 +1939,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() {
|
||||
@@ -2106,16 +1968,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);
|
||||
});
|
||||
@@ -2139,26 +2005,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;
|
||||
}
|
||||
// Settings menu is a transient dropdown, not a modal overlay, but
|
||||
// the global Escape handler must not reach hideDashboard() while
|
||||
// it's open — that would wipe the composer out from under the user
|
||||
// (hideDashboard clears dashboard-input.value and _dashboardStagedFiles).
|
||||
// The menu's own keydown handler (registered async via setTimeout(0)
|
||||
// in openSettingsMenu) handles Escape and Tab.
|
||||
if (_settingsMenu) 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();
|
||||
@@ -2249,6 +2098,7 @@ function initWorkstreams() {
|
||||
// roster + stream, the dashboard lists, health polling, and pending MCP
|
||||
// consents. It does NOT auto-run at parse time (the shell sequences it).
|
||||
function boot() {
|
||||
_initSavedWsTable(); // substrate modules have evaluated by boot time
|
||||
initLogin();
|
||||
pollHealth();
|
||||
loadInterfaceSettings();
|
||||
|
||||
+162
-109
@@ -4,6 +4,10 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>turnstone</title>
|
||||
<link
|
||||
rel="icon"
|
||||
href="data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2032%2032'%3E%3Crect%20width='32'%20height='32'%20rx='7'%20fill='%230e1013'/%3E%3Cpath%20d='M8%2022a8%208%200%201%201%2016%200'%20fill='none'%20stroke='%23e5a042'%20stroke-width='3'%20stroke-linecap='round'/%3E%3Cpath%20d='M16%2021%20L21%2014'%20stroke='%23e5a042'%20stroke-width='3'%20stroke-linecap='round'/%3E%3C/svg%3E"
|
||||
/>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
@@ -18,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>
|
||||
@@ -303,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>
|
||||
@@ -362,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
|
||||
@@ -371,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>
|
||||
@@ -555,19 +600,27 @@
|
||||
},
|
||||
];
|
||||
</script>
|
||||
<script src="/shared/utils.js"></script>
|
||||
<script src="/shared/cards.js"></script>
|
||||
<script src="/shared/toast.js"></script>
|
||||
<script src="/shared/composer.js"></script>
|
||||
<script src="/shared/composer_attachments.js"></script>
|
||||
<script src="/shared/composer_queue.js"></script>
|
||||
<script src="/shared/status_bar.js"></script>
|
||||
<!-- Two script lanes (mirrors console/static/index.html):
|
||||
CLASSIC: theme.js (FOUC — paints the stored theme at parse time),
|
||||
vendored katex/hljs (lazy typeof-guarded by renderer.js), and the
|
||||
legacy app.js bundle.
|
||||
MODULE (deferred): the shared substrate — each exports its API and
|
||||
installs a transitional window bridge for app.js, which only touches
|
||||
the globals at boot/event time (after modules evaluate). -->
|
||||
<script src="/shared/theme.js"></script>
|
||||
<script src="/shared/auth.js"></script>
|
||||
<script src="/shared/kb.js"></script>
|
||||
<script type="module" src="/shared/utils.js"></script>
|
||||
<script type="module" src="/shared/cards.js"></script>
|
||||
<script type="module" src="/shared/toast.js"></script>
|
||||
<script type="module" src="/shared/composer.js"></script>
|
||||
<script type="module" src="/shared/composer_attachments.js"></script>
|
||||
<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>
|
||||
<script src="/shared/renderer.js"></script>
|
||||
<script type="module" src="/shared/renderer.js"></script>
|
||||
<!-- The shell module imports the interactive pane (a /shared ES module), so
|
||||
it is not <script>-tagged here. app.js is classic and defines the
|
||||
window.TS_APP / TS_ADMIN seams the deferred shell reads after it. -->
|
||||
|
||||
@@ -53,135 +53,13 @@
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
.ws-tab.active {
|
||||
background: var(--bg-highlight);
|
||||
color: var(--fg-bright);
|
||||
border-bottom: 2px solid var(--accent);
|
||||
}
|
||||
.ws-tab.active .tab-chevron {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.ws-tab:hover .tab-wsid,
|
||||
.ws-tab.active .tab-wsid {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
/* .card-wsid moved to /shared/cards.css (with vertical-align added so it
|
||||
aligns with .card-meta). Single source for both surfaces. */
|
||||
|
||||
#new-tab-btn {
|
||||
background: none;
|
||||
border: 1px dashed var(--border-strong);
|
||||
color: var(--fg-dim);
|
||||
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s,
|
||||
border-color 0.15s;
|
||||
}
|
||||
#new-tab-btn:hover {
|
||||
background: var(--bg-highlight);
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
#split-btn.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Tab dropdown menu */
|
||||
@keyframes dropdown-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
.split-handle:hover,
|
||||
.split-handle:active,
|
||||
.split-handle.dragging {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
/* Pane */
|
||||
/* 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
|
||||
owns those affordances now (shared_static/shell.css). */
|
||||
.pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -191,74 +69,6 @@
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.pane.focused {
|
||||
outline: 1px solid var(--accent-dim);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
.multi-pane .pane.focused .pane-header {
|
||||
border-bottom-color: var(--accent);
|
||||
background: var(--bg-highlight);
|
||||
}
|
||||
|
||||
/* Pane header — only visible in multi-pane mode */
|
||||
.pane-header {
|
||||
display: none;
|
||||
}
|
||||
.multi-pane .pane-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 2px 8px;
|
||||
background: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
min-height: 24px;
|
||||
}
|
||||
.pane-ws-name {
|
||||
font-size: 11px;
|
||||
color: var(--fg-dim);
|
||||
font-family: var(--font-mono);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pane.focused .pane-ws-name {
|
||||
color: var(--accent);
|
||||
}
|
||||
.pane-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.pane-action-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--fg-dim);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
line-height: 1;
|
||||
opacity: 0.3;
|
||||
transition:
|
||||
opacity 0.15s,
|
||||
color 0.1s,
|
||||
background 0.1s;
|
||||
}
|
||||
.pane.focused .pane-action-btn {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.pane-header:hover .pane-action-btn,
|
||||
.pane-action-btn:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
.pane-action-btn:hover {
|
||||
color: var(--fg-bright);
|
||||
background: var(--bg-highlight);
|
||||
}
|
||||
.pane-close-btn:hover {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Messages
|
||||
@@ -897,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;
|
||||
@@ -972,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 {
|
||||
@@ -1736,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
|
||||
========================================================================== */
|
||||
@@ -1906,10 +1546,6 @@ audio.media-player {
|
||||
animation: none;
|
||||
content: "...";
|
||||
}
|
||||
.ws-tab,
|
||||
.ws-tab .tab-chevron,
|
||||
#new-tab-btn,
|
||||
#split-btn,
|
||||
.ts-approval-btn,
|
||||
.ts-approval-feedback,
|
||||
.composer button,
|
||||
@@ -1920,15 +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,
|
||||
.split-handle,
|
||||
.pane-action-btn,
|
||||
.pane-ctx-item,
|
||||
.ws-tab-dropdown-item {
|
||||
.media-play-btn {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -2207,52 +1835,3 @@ audio.media-player {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "anthropic"
|
||||
version = "0.107.1"
|
||||
version = "0.108.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
@@ -184,9 +184,9 @@ dependencies = [
|
||||
{ name = "sniffio" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/f1/c6076a92e0bf6b0dfa126e213b3f9e8a510acd73567953210713aae6c256/anthropic-0.107.1.tar.gz", hash = "sha256:8e7169a6ab57fb806b778d9af018c867bad688144efec8969cdb4c5ccecd6670", size = 856312, upload-time = "2026-06-07T17:18:57.358Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ae/c7/d7f6d2e3975893958081f0282751217757333a3830d0d95859023d7006d0/anthropic-0.108.0.tar.gz", hash = "sha256:91b70253debb477a99f7ca43dac3f71e52207db79d4b06f104080b8dd1693e3b", size = 909409, upload-time = "2026-06-09T16:37:43.584Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/86/0e/71432f0777a263701955a23ebcc6650485c2753be9afbce2a6a8d72526e3/anthropic-0.107.1-py3-none-any.whl", hash = "sha256:b74338d08000ba105dfc8adae29af3713ece845a4bffec9986a20697e087c7b3", size = 838729, upload-time = "2026-06-07T17:18:58.729Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/40/75a937ddd8f230ec129d27de60df69ce8afcab1d0b15f7d651a5a95fac8a/anthropic-0.108.0-py3-none-any.whl", hash = "sha256:bdee7b14c13cf5a60b2c8ae0cf195720e0ea7fd8ab90df5a3899c50f1c91c4be", size = 870079, upload-time = "2026-06-09T16:37:44.895Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2324,7 +2324,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "turnstone"
|
||||
version = "1.6.0a12"
|
||||
version = "1.6.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "alembic" },
|
||||
@@ -2377,7 +2377,7 @@ requires-dist = [
|
||||
{ name = "aiohttp", marker = "extra == 'slack'", specifier = ">=3.9" },
|
||||
{ name = "aiohttp", marker = "extra == 'test'", specifier = ">=3.9" },
|
||||
{ name = "alembic", specifier = ">=1.14" },
|
||||
{ name = "anthropic", specifier = ">=0.39" },
|
||||
{ name = "anthropic", specifier = ">=0.108" },
|
||||
{ name = "bcrypt", specifier = ">=4.0" },
|
||||
{ name = "croniter", specifier = ">=3.0" },
|
||||
{ name = "cryptography", specifier = ">=42" },
|
||||
|
||||
Reference in New Issue
Block a user