mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-13 23:42:25 -06:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e11929ba0 | |||
| d9e9a41b17 | |||
| 848b2cc1fb | |||
| 1946002618 | |||
| 4bce6abc7c | |||
| c19432f12a | |||
| 23007e3ac5 | |||
| f44886a55f | |||
| 3803feb008 | |||
| d0e9aa3dbe | |||
| 81a3eaecce | |||
| 9b75a12848 | |||
| 695a335744 | |||
| 9e0c86e78a | |||
| c15dcee1f5 | |||
| b3c3acc5d1 | |||
| 6bb47cad2d |
+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
|
||||
|
||||
@@ -237,14 +237,21 @@ Key properties:
|
||||
tool with a fresh timeout.
|
||||
- **Modes** — `mode="any"` returns as soon as one child reaches a
|
||||
real terminal state (`idle` / `error` / `closed` / `deleted`);
|
||||
`mode="all"` waits for every polled child.
|
||||
`mode="all"` waits for every polled child to reach a real
|
||||
terminal state.
|
||||
- **Progress throttling** — the poll loop runs every 500 ms but the
|
||||
SSE emission is diff-on-state-change plus a 5-second heartbeat. A
|
||||
600 s wait generates O(dozens) of progress events, not 1200.
|
||||
- **Denied rows** — an id the caller doesn't own (cross-tenant) or a
|
||||
missing row is reported as a `denied` state in the results dict;
|
||||
`mode="any"` won't satisfy on a pure-denied list (the LLM should
|
||||
treat it as a config error, not a completion).
|
||||
- **Unresolvable ids** — ws_ids are validated up front (exactly
|
||||
32 hex chars; copy them verbatim): a malformed id fails the call
|
||||
immediately with did-you-mean suggestions and a roster of the
|
||||
coord's children. An id the caller doesn't own, a missing row, or
|
||||
a child hard-deleted mid-wait is reported as `state="not_found"`
|
||||
and aborts the wait on the tick that observes it (top-level
|
||||
`error` / `not_found` / `children` fields, `complete=false`) — the
|
||||
LLM should fix the id and re-issue, not conclude the child died.
|
||||
Foreign and missing collapse into one shape, so the wait can't be
|
||||
used as an existence oracle.
|
||||
|
||||
Prefer `wait_for_workstream` over polling `inspect_workstream` in a
|
||||
loop — a wait consumes one assistant turn regardless of how long the
|
||||
|
||||
+25
-11
@@ -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": "...",
|
||||
|
||||
+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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
+3
-2
@@ -4,10 +4,11 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "turnstone"
|
||||
version = "1.6.0rc2"
|
||||
version = "1.6.1"
|
||||
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"]
|
||||
|
||||
+105
-15
@@ -26,6 +26,17 @@ UI harness (?open=): new-ws · new-ws-fork · edit-title · delete-ws ·
|
||||
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.
|
||||
|
||||
@@ -212,8 +223,9 @@ UI_TEMPLATE = """<!doctype html>
|
||||
"""
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Console harness — the admin pane fragment hosts the shelves; body-level
|
||||
# hatch dialogs (confirm/token/install/batch) ride along inside it.
|
||||
# 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>
|
||||
@@ -222,19 +234,51 @@ CONSOLE_TEMPLATE = """<!doctype html>
|
||||
<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" />
|
||||
<style>
|
||||
body { padding: 0; }
|
||||
#view-admin { padding: 20px; height: 100vh; box-sizing: border-box; }
|
||||
#admin-layout { height: 100%; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="view-admin">
|
||||
<!-- FRAGMENT:BEGIN -->
|
||||
<!-- FRAGMENT:END -->
|
||||
<!-- 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 () {
|
||||
@@ -331,6 +375,32 @@ CONSOLE_TEMPLATE = """<!doctype html>
|
||||
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");
|
||||
@@ -361,6 +431,22 @@ CONSOLE_TEMPLATE = """<!doctype html>
|
||||
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>
|
||||
@@ -387,11 +473,15 @@ def build(out: Path) -> None:
|
||||
|
||||
symlink(con / "shared", ROOT / "turnstone/shared_static")
|
||||
symlink(con / "console-static", ROOT / "turnstone/console/static")
|
||||
(con / "livepass.html").write_text(
|
||||
inject(CONSOLE_TEMPLATE, "FRAGMENT", extract_admin_fragment()),
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(f"{con}/livepass.html — admin fragment embedded")
|
||||
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:
|
||||
|
||||
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"
|
||||
|
||||
@@ -285,6 +285,38 @@ def test_system_turn_dedups_against_history_by_event_id() -> None:
|
||||
"replayHistory (and the live handler) must record system-turn ids for the dedup set."
|
||||
)
|
||||
|
||||
# Pin the wiring on BOTH read paths, scoped to its method — a refactor that
|
||||
# keeps the Set but drops the live-handler consultation (or the
|
||||
# replayHistory-side record) silently re-opens the double-render while the
|
||||
# file-global checks above still pass.
|
||||
live_start = body.index('case "system_turn":')
|
||||
# End at the NEXT switch case, not the first ``break;`` — the dedup-skip
|
||||
# path breaks before the ``.add(``, so a ``break;``-bounded slice would
|
||||
# drop the record half and false-fail the ``.add(`` assertion below.
|
||||
# Whitespace-tolerant so a reformat can't silently break the bound.
|
||||
next_case = re.search(r'\n\s*case "', body[live_start + 1 :])
|
||||
assert next_case, (
|
||||
"no switch case found after system_turn to bound the pin slice — if "
|
||||
"system_turn became the last case, re-anchor this pin's end marker."
|
||||
)
|
||||
live_block = body[live_start : live_start + 1 + next_case.start()]
|
||||
assert re.search(r"_renderedSystemEventIds[\s\S]*?\.\s*has\(", live_block), (
|
||||
"the live system_turn handler must CONSULT the dedup set (skip an id "
|
||||
"already painted from /history), not merely reference the Set elsewhere."
|
||||
)
|
||||
assert re.search(r"_renderedSystemEventIds[\s\S]*?\.\s*add\(", live_block), (
|
||||
"the live system_turn handler must RECORD the id it renders so a later "
|
||||
"/history re-render (clear_ui) doesn't repaint it."
|
||||
)
|
||||
|
||||
replay_start = _pane_method_offset(body, "replayHistory")
|
||||
replay_end = _pane_method_offset(body, "_attachRetryToLastAssistant")
|
||||
replay_block = body[replay_start:replay_end]
|
||||
assert re.search(r"_renderedSystemEventIds[\s\S]*?\.\s*add\(", replay_block), (
|
||||
"replayHistory must record each replayed system row's event_id so the "
|
||||
"live system_turn handler can dedup against it."
|
||||
)
|
||||
|
||||
|
||||
def test_retry_walk_skips_operator_context_cards() -> None:
|
||||
"""Interactive twin of the coord retry-skip guard.
|
||||
@@ -416,6 +448,26 @@ def test_phase8_mcp_error_helpers_defined() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_media_player_activation_not_duplicated_in_standalone() -> None:
|
||||
"""The media-player activation (``_loadHls`` / ``_activatePlayer`` + the
|
||||
click/keydown delegate) moved into the shared interactive pane so BOTH the
|
||||
standalone server and the console activate the Play button. The standalone
|
||||
app.js must NOT keep its own copy — a duplicate document-level listener
|
||||
would double-fire on the standalone (two players swapped in) while the lift
|
||||
is what fixed the console (where app.js was never the host). Pin the
|
||||
standalone clean so the stale copy can't drift back in."""
|
||||
app = _APP_JS.read_text(encoding="utf-8")
|
||||
for name in ("_loadHls", "_activatePlayer", "_isHlsUrl", "media-play-btn"):
|
||||
assert name not in app, (
|
||||
f"standalone app.js must not re-declare the lifted media player "
|
||||
f"({name!r}) — it lives in shared_static/interactive.js now"
|
||||
)
|
||||
# The lift target carries the real implementation (the click delegate too).
|
||||
inter = _INTERACTIVE_JS.read_text(encoding="utf-8")
|
||||
assert "function _activatePlayer(" in inter
|
||||
assert "activateMediaPlayButton(btn)" in inter
|
||||
|
||||
|
||||
def test_phase8_settings_panel_handlers_defined() -> None:
|
||||
"""The settings modal exposes four entry points that the inline
|
||||
``onclick`` attributes in index.html depend on. Renaming or
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -8,6 +8,8 @@ visitor lands on the page but all API calls fail).
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
from starlette.applications import Starlette
|
||||
from starlette.routing import Route
|
||||
@@ -401,6 +403,49 @@ def test_coord_dedups_system_turn_against_history_by_event_id():
|
||||
"false-skip after clear_ui / replay_truncated."
|
||||
)
|
||||
|
||||
# The seam must be wired on BOTH read paths, not merely present somewhere
|
||||
# in the file — a refactor that keeps the Set but drops the live-handler
|
||||
# consultation (or the history-side record) silently re-opens the
|
||||
# double-render. Scope each assertion to its block so the wiring, not the
|
||||
# bare symbol, is pinned. (A dedupe-neutered factory — guard short-circuited
|
||||
# to ``false`` — still contains ``renderedSystemEventIds.has(`` and so
|
||||
# passes the file-global checks above; these slice checks catch it.)
|
||||
sys_case = body.index('case "system_turn":')
|
||||
# End at the NEXT switch case, not the first ``break;`` — the dedup-skip
|
||||
# ``...has(sysEid)) break;`` is itself a break that precedes the ``.add(``,
|
||||
# so a ``break;``-bounded slice would drop the record half.
|
||||
# Whitespace-tolerant so a reformat can't silently break the bound.
|
||||
next_case = re.search(r'\n\s*case "', body[sys_case + 1 :])
|
||||
assert next_case, (
|
||||
"no switch case found after system_turn to bound the pin slice — if "
|
||||
"system_turn became the last case, re-anchor this pin's end marker."
|
||||
)
|
||||
live_block = body[sys_case : sys_case + 1 + next_case.start()]
|
||||
assert "renderedSystemEventIds.has(" in live_block, (
|
||||
"the live system_turn handler must CONSULT the dedup set (skip an id "
|
||||
"already painted from /history) — not just reference the Set elsewhere."
|
||||
)
|
||||
assert "renderedSystemEventIds.add(" in live_block, (
|
||||
"the live system_turn handler must RECORD the id it renders so a later "
|
||||
"/history re-render (clear_ui) doesn't repaint it."
|
||||
)
|
||||
|
||||
# The history render path must seed the set from each replayed system row's
|
||||
# event_id, so a subsequent live replay of the same id is skipped. Bound
|
||||
# the slice structurally — from the system-role branch to the next role
|
||||
# branch in the same chain (falling back to a generous window when it's
|
||||
# the last branch) — so adding comments/fields inside the branch can't
|
||||
# false-fail a pin that only cares about the wiring.
|
||||
assert 'role === "system"' in body
|
||||
sys_replay = body.index('role === "system"', body.index("refetchHistory"))
|
||||
next_role = re.search(r"role\s*===", body[sys_replay + 1 :])
|
||||
replay_end = sys_replay + 1 + next_role.start() if next_role else sys_replay + 1500
|
||||
replay_window = body[sys_replay:replay_end]
|
||||
assert "renderedSystemEventIds.add(" in replay_window, (
|
||||
"the history render's system-role branch must record each replayed "
|
||||
"turn's event_id so the live system_turn handler can dedup against it."
|
||||
)
|
||||
|
||||
|
||||
def test_coord_retry_walk_skips_operator_context_cards():
|
||||
"""Retry must NOT regenerate a stale assistant turn when the last DOM row is
|
||||
|
||||
@@ -184,6 +184,42 @@ def test_approval_keyboard_shortcuts_wired() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_media_playback_lifted_and_pane_owned() -> None:
|
||||
"""The media Play affordance is rendered by the pane (buildPlayButton /
|
||||
buildMediaEmbed), so its activation must live in the pane too — the old
|
||||
standalone wired a DOCUMENT-level click/keydown listener in app.js, which
|
||||
the console host never loaded (so the button was dead in console-hosted
|
||||
panes). The fix mirrors the approval-keydown pattern: a pane-owned listener
|
||||
on this.el, root-scoped via closest(".media-play-btn"). Pin both the
|
||||
lifted helpers and the pane wiring so the document-level regression can't
|
||||
silently come back."""
|
||||
body = _INTERACTIVE.read_text(encoding="utf-8")
|
||||
# The lifted activation machinery now lives in the shared module.
|
||||
for fn in (
|
||||
"function _loadHls(",
|
||||
"function _isHlsUrl(",
|
||||
"function _activatePlayer(",
|
||||
"function activateMediaPlayButton(",
|
||||
):
|
||||
assert fn in body, f"media player helper must be lifted into the pane: {fn}"
|
||||
# The HLS vendor is fetched by absolute /shared/ URL (resolves in BOTH the
|
||||
# standalone server and the console, where /shared is mounted at the root).
|
||||
assert 'script.src = "/shared/hls-1.6.16/hls.min.js";' in body
|
||||
# Pane-owned + root-scoped — NOT a document-level delegated listener.
|
||||
assert 'this.el.addEventListener("click"' in body, (
|
||||
"media play must be wired on this.el (pane-owned), not document"
|
||||
)
|
||||
assert 'e.target.closest(".media-play-btn")' in body, (
|
||||
"the play handler must be root-scoped via closest, not a document-wide id"
|
||||
)
|
||||
assert "activateMediaPlayButton(btn)" in body
|
||||
collapsed = _strip_comments(body)
|
||||
assert 'document.addEventListener("click"' not in collapsed, (
|
||||
"the pane must not register a document-level click delegate — that is "
|
||||
"the standalone regression that left console panes dead"
|
||||
)
|
||||
|
||||
|
||||
def test_controller_terminal_dead_state() -> None:
|
||||
"""Lifecycle round 2: the console controller must STOP reconnect-polling a
|
||||
session that is gone (closed / evicted / node restarted) — three consecutive
|
||||
|
||||
@@ -2913,6 +2913,181 @@ class TestMemoryCompositionDeferral:
|
||||
assert session._system_composed_with_context is False
|
||||
|
||||
|
||||
class TestMemoryAccessTouch:
|
||||
"""Access metadata (``access_count`` / ``last_accessed``) moves only when
|
||||
the model actually sees a memory: the injected top-k during composition,
|
||||
and explicit search/get reads via the memory tool. Save/list and the
|
||||
wider candidate pool must NOT bump the counter.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _access_count(name: str, scope: str = "global", scope_id: str = "") -> int:
|
||||
from turnstone.core.storage import get_storage
|
||||
|
||||
mem = get_storage().get_structured_memory_by_name(name, scope, scope_id)
|
||||
assert mem is not None, f"memory {name!r} not found"
|
||||
return int(mem["access_count"])
|
||||
|
||||
@staticmethod
|
||||
def _save(name: str, content: str) -> None:
|
||||
from turnstone.core.memory import save_structured_memory
|
||||
|
||||
save_structured_memory(name, content, scope="global")
|
||||
|
||||
@staticmethod
|
||||
def _empty_session() -> ChatSession:
|
||||
"""A session whose __init__ composed before any memory existed.
|
||||
|
||||
The constructor composes the system prefix once; building it before
|
||||
the memories are saved keeps that first (empty) compose from touching
|
||||
rows, so the tests observe only the turn-driven recompose below.
|
||||
"""
|
||||
return _make_session(ws_id="ws-1", user_id="user-1")
|
||||
|
||||
@staticmethod
|
||||
def _compose_turn(session: ChatSession, query: str) -> None:
|
||||
"""Drive one user turn's worth of composition.
|
||||
|
||||
Mirrors ``send``: a fresh user turn invalidates the per-turn memory
|
||||
caches, then the prefix recomposes against the new query.
|
||||
"""
|
||||
session._invalidate_memory_cache()
|
||||
session.messages.append(turn_from_dict({"role": "user", "content": query}))
|
||||
session._init_system_messages()
|
||||
|
||||
def test_composition_touches_injected_memories(self, tmp_db):
|
||||
session = self._empty_session()
|
||||
self._save("kafka_runbook", "restart the kafka broker pods")
|
||||
self._save("kafka_alerts", "kafka consumer lag alert thresholds")
|
||||
self._compose_turn(session, "how do I restart kafka")
|
||||
# Both query-matching memories were injected, so both got touched once.
|
||||
assert self._access_count("kafka_runbook") == 1
|
||||
assert self._access_count("kafka_alerts") == 1
|
||||
|
||||
def test_composition_skips_unmatched_candidates(self, tmp_db):
|
||||
"""The candidate pool is a superset of the injected set — a memory
|
||||
that loses BM25 ranking (no query overlap) must NOT be touched."""
|
||||
session = self._empty_session()
|
||||
self._save("kafka_runbook", "restart the kafka broker pods")
|
||||
self._save("garden_notes", "tomato watering schedule midsummer")
|
||||
self._compose_turn(session, "restart kafka broker pods status")
|
||||
# The matching memory was injected and touched.
|
||||
assert self._access_count("kafka_runbook") == 1
|
||||
# The non-matching one was a candidate but never injected.
|
||||
assert self._access_count("garden_notes") == 0
|
||||
# Sanity: it really was in the visible candidate pool.
|
||||
visible = {m["name"] for m in session._list_visible_memories()}
|
||||
assert "garden_notes" in visible
|
||||
|
||||
def test_composition_touches_each_memory_once_per_turn(self, tmp_db):
|
||||
"""``_init_system_messages`` runs many times within a turn (tool
|
||||
results, MCP refresh); the injected set must be touched at most once
|
||||
per memory between user turns, not once per recompose."""
|
||||
session = self._empty_session()
|
||||
self._save("kafka_runbook", "restart the kafka broker pods")
|
||||
self._compose_turn(session, "how do I restart kafka")
|
||||
# Several mid-turn recomposes (no new user turn between them).
|
||||
session._init_system_messages()
|
||||
session._init_system_messages()
|
||||
assert self._access_count("kafka_runbook") == 1
|
||||
# A genuinely new turn lets the same memory be counted again.
|
||||
self._compose_turn(session, "kafka again please")
|
||||
assert self._access_count("kafka_runbook") == 2
|
||||
|
||||
def test_composition_touches_exactly_the_injected_keys(self, tmp_db):
|
||||
"""Spy the touch boundary and assert the keys match the names the
|
||||
composer rendered into the ``<memories>`` block — exactly, not the
|
||||
candidate pool."""
|
||||
session = self._empty_session()
|
||||
self._save("kafka_runbook", "restart the kafka broker pods")
|
||||
self._save("garden_notes", "tomato watering schedule midsummer")
|
||||
session._invalidate_memory_cache()
|
||||
session.messages.append(
|
||||
turn_from_dict({"role": "user", "content": "restart kafka broker pods status"})
|
||||
)
|
||||
touched: list[tuple[str, str, str]] = []
|
||||
with patch(
|
||||
"turnstone.core.session.touch_structured_memories",
|
||||
side_effect=lambda keys: touched.extend(keys),
|
||||
):
|
||||
session._init_system_messages()
|
||||
joined = "\n".join(m["content"] for m in session.system_messages if m["role"] == "system")
|
||||
touched_names = {name for name, _, _ in touched}
|
||||
assert touched_names == {"kafka_runbook"}
|
||||
assert '<memory name="kafka_runbook"' in joined
|
||||
assert '<memory name="garden_notes"' not in joined
|
||||
|
||||
def test_composition_survives_touch_storage_error(self, tmp_db):
|
||||
"""A storage blow-up inside the touch must not break composition —
|
||||
the facade swallows it and the memory block still lands."""
|
||||
from turnstone.core.storage import get_storage
|
||||
|
||||
session = self._empty_session()
|
||||
self._save("kafka_runbook", "restart the kafka broker pods")
|
||||
session._invalidate_memory_cache()
|
||||
session.messages.append(
|
||||
turn_from_dict({"role": "user", "content": "how do I restart kafka"})
|
||||
)
|
||||
with patch.object(
|
||||
get_storage(),
|
||||
"touch_structured_memories",
|
||||
side_effect=RuntimeError("storage exploded"),
|
||||
):
|
||||
session._init_system_messages()
|
||||
joined = "\n".join(m["content"] for m in session.system_messages if m["role"] == "system")
|
||||
assert '<memory name="kafka_runbook"' in joined
|
||||
|
||||
def test_search_action_touches_returned_hits(self, tmp_db):
|
||||
session = self._empty_session()
|
||||
self._save("kafka_runbook", "restart the kafka broker pods")
|
||||
item = session._prepare_memory("call_1", {"action": "search", "query": "kafka"})
|
||||
assert "error" not in item
|
||||
session._exec_memory(item)
|
||||
assert self._access_count("kafka_runbook") == 1
|
||||
|
||||
def test_get_action_touches_fetched_memory(self, tmp_db):
|
||||
session = self._empty_session()
|
||||
self._save("kafka_runbook", "restart the kafka broker pods")
|
||||
item = session._prepare_memory(
|
||||
"call_1", {"action": "get", "name": "kafka_runbook", "scope": "global"}
|
||||
)
|
||||
assert "error" not in item
|
||||
session._exec_memory(item)
|
||||
assert self._access_count("kafka_runbook") == 1
|
||||
|
||||
def test_get_miss_touches_nothing(self, tmp_db):
|
||||
session = self._empty_session()
|
||||
self._save("kafka_runbook", "restart the kafka broker pods")
|
||||
item = session._prepare_memory(
|
||||
"call_1", {"action": "get", "name": "no_such_mem", "scope": "global"}
|
||||
)
|
||||
_, msg = session._exec_memory(item)
|
||||
assert "not found" in msg
|
||||
# The existing row must not be collaterally touched by a miss.
|
||||
assert self._access_count("kafka_runbook") == 0
|
||||
|
||||
def test_list_action_does_not_touch(self, tmp_db):
|
||||
session = self._empty_session()
|
||||
self._save("kafka_runbook", "restart the kafka broker pods")
|
||||
item = session._prepare_memory("call_1", {"action": "list"})
|
||||
session._exec_memory(item)
|
||||
assert self._access_count("kafka_runbook") == 0
|
||||
|
||||
def test_save_action_does_not_touch_access_count(self, tmp_db):
|
||||
"""The save action handler itself must not bump ``access_count`` —
|
||||
that counter is read traffic only. (The recompose a save triggers
|
||||
may surface the row via the composition path; that is exercised by
|
||||
the composition tests. Suppressed here to isolate the handler.)"""
|
||||
session = self._empty_session()
|
||||
item = session._prepare_memory(
|
||||
"call_1",
|
||||
{"action": "save", "name": "kafka_runbook", "content": "x", "scope": "global"},
|
||||
)
|
||||
with patch.object(session, "_init_system_messages"):
|
||||
session._exec_memory(item)
|
||||
assert self._access_count("kafka_runbook") == 0
|
||||
|
||||
|
||||
class TestMetacognitiveBuffers:
|
||||
"""Nudges drain through advisory channels, not the system message."""
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""turnstone - Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."""
|
||||
|
||||
__version__ = "1.6.0rc2"
|
||||
__version__ = "1.6.1"
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -178,6 +178,7 @@ function showAdmin(tab) {
|
||||
}
|
||||
|
||||
function switchAdminTab(tab) {
|
||||
const tabChanged = tab !== _adminTab;
|
||||
_adminTab = tab;
|
||||
// Hide no-permissions empty state if it was showing
|
||||
const noPerms = document.getElementById("admin-no-permissions");
|
||||
@@ -207,6 +208,15 @@ function switchAdminTab(tab) {
|
||||
if (el) el.style.display = panels[p] === tab ? "" : "none";
|
||||
}
|
||||
|
||||
// One #admin-content scroller serves every panel — a leftover offset from
|
||||
// a tall tab must not carry into the next (the app.js #main reset is the
|
||||
// precedent for pane-local navigation). Same-tab re-entry (showAdmin on
|
||||
// pane focus) keeps the user's place.
|
||||
if (tabChanged) {
|
||||
const contentEl = document.getElementById("admin-content");
|
||||
if (contentEl) contentEl.scrollTop = 0;
|
||||
}
|
||||
|
||||
if (tab === "users") loadAdminUsers();
|
||||
if (tab === "tokens") _populateTokenUserSelect();
|
||||
if (tab === "channels") _populateChannelUserSelect();
|
||||
|
||||
@@ -119,21 +119,23 @@
|
||||
<button
|
||||
type="button"
|
||||
id="persona-coordinator"
|
||||
class="persona-btn active"
|
||||
class="persona-btn persona-btn--coord active"
|
||||
role="radio"
|
||||
aria-checked="true"
|
||||
tabindex="0"
|
||||
>
|
||||
<span class="persona-led" aria-hidden="true"></span>
|
||||
Coordinator
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
id="persona-interactive"
|
||||
class="persona-btn"
|
||||
class="persona-btn persona-btn--int"
|
||||
role="radio"
|
||||
aria-checked="false"
|
||||
tabindex="-1"
|
||||
>
|
||||
<span class="persona-led" aria-hidden="true"></span>
|
||||
Interactive
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
@@ -951,6 +961,11 @@
|
||||
* 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;
|
||||
@@ -1090,6 +1105,11 @@ label.toggle-switch.toggle--flush {
|
||||
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;
|
||||
|
||||
@@ -86,6 +86,7 @@ from turnstone.core.memory import (
|
||||
search_visible_structured_memories,
|
||||
set_message_attachments,
|
||||
set_workstream_alias,
|
||||
touch_structured_memories,
|
||||
update_workstream_title,
|
||||
)
|
||||
from turnstone.core.memory_relevance import (
|
||||
@@ -1031,6 +1032,10 @@ class ChatSession:
|
||||
# tool results) and the recent-context string is identical across
|
||||
# them. Invalidated on user-turn append and on memory write/delete.
|
||||
self._mem_search_cache: dict[tuple[str, str, int], list[dict[str, str]]] = {}
|
||||
# Per-turn dedup for composition touches: ``_init_system_messages`` runs
|
||||
# many times within a turn, so the injected set is touched at most once
|
||||
# per memory per turn. Cleared alongside the search cache.
|
||||
self._touched_memory_keys: set[tuple[str, str, str]] = set()
|
||||
self._ws_id = ws_id or uuid.uuid4().hex
|
||||
self._title_generated = False
|
||||
self._read_files: set[str] = set()
|
||||
@@ -2873,6 +2878,9 @@ class ChatSession:
|
||||
candidates=len(visible_mems),
|
||||
injected=len(relevant),
|
||||
)
|
||||
# Access metadata tracks what the model actually saw — touch the
|
||||
# injected top-k, not the candidate pool.
|
||||
self._touch_injected_memories(relevant)
|
||||
if relevant:
|
||||
dev_parts.append("")
|
||||
dev_parts.append(build_memory_context(relevant))
|
||||
@@ -7439,6 +7447,7 @@ class ChatSession:
|
||||
def _invalidate_memory_cache(self) -> None:
|
||||
"""Drop the per-turn search cache; call on user-turn append + memory writes."""
|
||||
self._mem_search_cache.clear()
|
||||
self._touched_memory_keys.clear()
|
||||
|
||||
def _select_memory_candidates(self, context: str) -> tuple[list[dict[str, str]], str]:
|
||||
"""Pick the candidate set fed into BM25 ranking.
|
||||
@@ -7476,6 +7485,38 @@ class ChatSession:
|
||||
return extra, "recency"
|
||||
return search_hits + extra, ("union" if extra else "search")
|
||||
|
||||
@staticmethod
|
||||
def _memory_keys(rows: list[dict[str, str]]) -> list[tuple[str, str, str]]:
|
||||
"""Build ``(name, scope, scope_id)`` touch keys from memory rows.
|
||||
|
||||
The storage read helpers return ``SELECT *`` rows, so all three
|
||||
columns are present.
|
||||
"""
|
||||
return [(r.get("name", ""), r.get("scope", ""), r.get("scope_id", "")) for r in rows]
|
||||
|
||||
def _touch_injected_memories(self, rows: list[dict[str, str]]) -> None:
|
||||
"""Touch the memories injected into the system prefix this turn.
|
||||
|
||||
``_init_system_messages`` recomposes many times per turn; gate on the
|
||||
per-turn touched-key set so each surfaced memory is counted at most
|
||||
once between user turns. Best-effort: the facade swallows storage
|
||||
errors, so a failed touch never breaks composition.
|
||||
"""
|
||||
fresh = [k for k in self._memory_keys(rows) if k not in self._touched_memory_keys]
|
||||
if not fresh:
|
||||
return
|
||||
self._touched_memory_keys.update(fresh)
|
||||
touch_structured_memories(fresh)
|
||||
|
||||
def _touch_read_memories(self, rows: list[dict[str, str]]) -> None:
|
||||
"""Touch memories returned by an explicit memory-tool read.
|
||||
|
||||
A search/get is a distinct user-driven access each time it runs, so
|
||||
these are counted unconditionally (not subject to the composition
|
||||
per-turn dedup). Best-effort via the facade.
|
||||
"""
|
||||
touch_structured_memories(self._memory_keys(rows))
|
||||
|
||||
def _check_metacognitive_nudge(self, user_message: str) -> tuple[str, str] | None:
|
||||
"""Check if a metacognitive nudge should fire for *user_message*.
|
||||
|
||||
@@ -10012,7 +10053,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",
|
||||
@@ -10023,7 +10074,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
|
||||
@@ -11479,6 +11530,7 @@ class ChatSession:
|
||||
found_scope = scope
|
||||
break
|
||||
if mem:
|
||||
self._touch_read_memories([mem])
|
||||
content = mem.get("content", "")
|
||||
desc = mem.get("description", "")
|
||||
mem_type = mem.get("type", "")
|
||||
@@ -11556,6 +11608,7 @@ class ChatSession:
|
||||
result_count=len(rows),
|
||||
query=item["query"][:120],
|
||||
)
|
||||
self._touch_read_memories(rows)
|
||||
if rows:
|
||||
lines = []
|
||||
for m in rows:
|
||||
|
||||
+1
-1
@@ -4091,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(
|
||||
|
||||
@@ -35,10 +35,18 @@
|
||||
narrow-pane sheet breakpoint (a SPLIT pane is narrow even on a desktop
|
||||
viewport, so the breakpoint is a container query, not a media query).
|
||||
contain:layout (implied by container-type) also makes this the containing
|
||||
block for the absolutely-positioned shelf. */
|
||||
block for the absolutely-positioned shelf.
|
||||
|
||||
overflow must be `clip`, never `hidden`: hidden makes the host a
|
||||
PROGRAMMATIC scroll container — focus-into-view (tabbing to a control,
|
||||
clicking a visually-hidden toggle input) silently scrolls it, dragging
|
||||
the docked shelf and the pane content out of alignment with no scrollbar
|
||||
to recover by. clip only clips paint; the host can never scroll, so
|
||||
scrolling stays where it belongs — the pane's interior scroller and the
|
||||
shelf's .sh-body. */
|
||||
.hatch-host {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
overflow: clip;
|
||||
container-type: inline-size;
|
||||
container-name: pane;
|
||||
}
|
||||
@@ -862,6 +870,10 @@
|
||||
margin-top: 8px;
|
||||
}
|
||||
.sh-body label.cap {
|
||||
/* The containing block for the visually-hidden abspos input below: without
|
||||
it the input sits at its static position OUTSIDE the .sh-body scroller,
|
||||
overhangs the shelf, and focus-scrolls the wrong ancestor. */
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
@@ -630,6 +630,28 @@ class Pane {
|
||||
}
|
||||
});
|
||||
|
||||
// Click-to-play for media embeds. Pane-owned (on this.el) and root-scoped
|
||||
// via closest(".media-play-btn") so every embedded L-shell pane activates
|
||||
// its own players — the old standalone wired this via a document-level
|
||||
// delegated listener in app.js, which the console host never loaded (so the
|
||||
// Play button was dead in console-hosted panes). Enter on a focused button
|
||||
// routes through the same path, mirroring the approval keydown above.
|
||||
this.el.addEventListener("click", (e) => {
|
||||
const btn = e.target.closest(".media-play-btn");
|
||||
if (!btn) return;
|
||||
e.preventDefault();
|
||||
activateMediaPlayButton(btn);
|
||||
});
|
||||
this.el.addEventListener("keydown", (e) => {
|
||||
if (e.key !== "Enter") return;
|
||||
const btn = e.target.closest(".media-play-btn");
|
||||
if (!btn || btn.disabled) return;
|
||||
// Single-path activation: preventDefault stops the browser's native
|
||||
// Enter-to-click from dispatching a second activation behind ours.
|
||||
e.preventDefault();
|
||||
activateMediaPlayButton(btn);
|
||||
});
|
||||
|
||||
// No pane header: the workstream name, persona, and state are shown by the
|
||||
// tab and the rail (Workspaces); the --skip-permissions banner lands in
|
||||
// messagesEl (see the host warningTarget). The standalone split-pane
|
||||
@@ -2693,6 +2715,143 @@ function _tryPrettyJson(text) {
|
||||
return _redactApiKeys(JSON.stringify(obj, null, 2));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HLS lazy-loader + click-to-play (lifted from the standalone app.js so
|
||||
// console-hosted panes activate media too). Follows the mermaid.js
|
||||
// lazy-load pattern in /shared/renderer.js: the vendor is fetched by absolute
|
||||
// /shared/ URL on first use, so it resolves in BOTH the standalone server and
|
||||
// the console (where /shared is mounted at the root and node-proxied panes
|
||||
// also reach it via /node/{id}/shared/).
|
||||
// ---------------------------------------------------------------------------
|
||||
let _hlsState = "idle";
|
||||
let _hlsQueue = [];
|
||||
|
||||
function _loadHls(callback) {
|
||||
if (_hlsState === "ready") {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
_hlsQueue.push(callback);
|
||||
if (_hlsState === "loading") return;
|
||||
_hlsState = "loading";
|
||||
const script = document.createElement("script");
|
||||
script.src = "/shared/hls-1.6.16/hls.min.js";
|
||||
script.onload = function () {
|
||||
_hlsState = "ready";
|
||||
const q = _hlsQueue;
|
||||
_hlsQueue = [];
|
||||
for (let i = 0; i < q.length; i++) q[i]();
|
||||
};
|
||||
script.onerror = function () {
|
||||
_hlsState = "idle";
|
||||
const q = _hlsQueue;
|
||||
_hlsQueue = [];
|
||||
// Fall through — _activatePlayer will use stream_url since Hls is undefined
|
||||
for (let i = 0; i < q.length; i++) q[i]();
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
function _isHlsUrl(url) {
|
||||
return typeof url === "string" && /\.m3u8(\?|$)/i.test(url);
|
||||
}
|
||||
|
||||
function _activatePlayer(btn) {
|
||||
const url = btn.dataset.streamUrl;
|
||||
const hlsUrl = btn.dataset.hlsUrl;
|
||||
const isAudio = btn.dataset.audioOnly === "true";
|
||||
const directStream = btn.dataset.directStream === "true";
|
||||
|
||||
const player = document.createElement(isAudio ? "audio" : "video");
|
||||
player.controls = true;
|
||||
player.autoplay = true;
|
||||
player.className = "media-player";
|
||||
|
||||
// Held so the error handler can tear the instance down before the player
|
||||
// node is replaced — otherwise its listeners/loader timers run detached.
|
||||
let hls = null;
|
||||
|
||||
// Prefer direct stream when the source supports it; fall back to HLS
|
||||
// only when transcoding is needed.
|
||||
if (directStream && url) {
|
||||
player.src = url;
|
||||
} else if (
|
||||
hlsUrl &&
|
||||
!isAudio &&
|
||||
typeof Hls !== "undefined" &&
|
||||
Hls.isSupported()
|
||||
) {
|
||||
hls = new Hls();
|
||||
hls.loadSource(hlsUrl);
|
||||
hls.attachMedia(player);
|
||||
} else if (
|
||||
hlsUrl &&
|
||||
!isAudio &&
|
||||
player.canPlayType("application/vnd.apple.mpegurl")
|
||||
) {
|
||||
player.src = hlsUrl;
|
||||
} else {
|
||||
player.src = url;
|
||||
}
|
||||
|
||||
player.addEventListener("error", function () {
|
||||
if (hls) {
|
||||
hls.destroy();
|
||||
hls = null; // media error events can repeat — never double-destroy
|
||||
}
|
||||
const card = player.closest(".media-embed");
|
||||
const titleEl = card ? card.querySelector(".media-card-title") : null;
|
||||
const label = titleEl ? ": " + titleEl.textContent : "";
|
||||
|
||||
const err = document.createElement("div");
|
||||
err.className = "media-player-error";
|
||||
err.setAttribute("role", "alert");
|
||||
err.textContent = "Failed to load stream" + label;
|
||||
|
||||
const retry = document.createElement("button");
|
||||
retry.className = "media-play-btn";
|
||||
retry.type = "button";
|
||||
retry.dataset.streamUrl = url;
|
||||
retry.dataset.hlsUrl = hlsUrl || "";
|
||||
retry.dataset.audioOnly = String(isAudio);
|
||||
retry.dataset.directStream = String(directStream);
|
||||
retry.setAttribute("aria-label", "Retry" + label);
|
||||
retry.appendChild(document.createTextNode("▶ Retry"));
|
||||
|
||||
const container = document.createElement("div");
|
||||
container.appendChild(err);
|
||||
container.appendChild(retry);
|
||||
player.replaceWith(container);
|
||||
});
|
||||
|
||||
btn.replaceWith(player);
|
||||
}
|
||||
|
||||
// Activate a clicked/Enter-pressed play button: show the loading affordance,
|
||||
// then ensure hls.js is loaded before swapping in the player when the source
|
||||
// needs it. The pane wires this from a root-scoped this.el listener.
|
||||
function activateMediaPlayButton(btn) {
|
||||
btn.disabled = true;
|
||||
const labelEl = btn.querySelector("span:last-child");
|
||||
if (labelEl) {
|
||||
labelEl.textContent = "Loading…";
|
||||
} else {
|
||||
btn.textContent = "▶ Loading…";
|
||||
}
|
||||
|
||||
const hlsUrl = btn.dataset.hlsUrl;
|
||||
const isAudio = btn.dataset.audioOnly === "true";
|
||||
|
||||
// If HLS URL present and not audio, ensure hls.js is loaded first
|
||||
if (hlsUrl && !isAudio && _isHlsUrl(hlsUrl)) {
|
||||
_loadHls(function () {
|
||||
_activatePlayer(btn);
|
||||
});
|
||||
} else {
|
||||
_activatePlayer(btn);
|
||||
}
|
||||
}
|
||||
|
||||
function buildMediaCard(item) {
|
||||
const card = document.createElement("div");
|
||||
card.className = "media-card";
|
||||
|
||||
@@ -723,6 +723,10 @@
|
||||
.tab-menu {
|
||||
animation: none;
|
||||
}
|
||||
.persona-btn,
|
||||
.persona-led {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* pane host — ONE pane visible per tab (no split; the mock's 2-up was a
|
||||
@@ -829,33 +833,76 @@
|
||||
}
|
||||
|
||||
/* ===== Dashboard session launcher — persona toggle (coordinator | interactive).
|
||||
A console-dashboard control; lives here because the L-shell loads shell.css. */
|
||||
A console-dashboard control; lives here because the L-shell loads shell.css.
|
||||
|
||||
The active option wears its KIND, not a neutral highlight: amber for
|
||||
coordinator, cyan for interactive — the same vocabulary as the pane-head
|
||||
.ptag chips and the rail's session rows, so "which kind am I starting"
|
||||
reads at a glance. Tints are 15% (sub-0.10 washes out at chip size) and
|
||||
colour is never alone: the kind LED + label weight carry the state too. */
|
||||
.launcher-personas {
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
margin-bottom: 10px;
|
||||
padding: 2px;
|
||||
border: 1px solid var(--hair);
|
||||
padding: 3px;
|
||||
border: 1px solid var(--hair-2);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--panel-2);
|
||||
background: var(--bg); /* recessed track — the .seg precedent */
|
||||
}
|
||||
.persona-btn {
|
||||
padding: 4px 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 5px 14px;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--ink-3);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
font-family: var(--font-ui);
|
||||
/* r-sm, matching the shared :focus-visible rule below — a literal here
|
||||
would make the corner radius pop on keyboard focus */
|
||||
border-radius: var(--r-sm);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.12s,
|
||||
color 0.12s;
|
||||
}
|
||||
.persona-btn:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
.persona-led {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--ink-4);
|
||||
opacity: 0.35;
|
||||
flex-shrink: 0;
|
||||
transition:
|
||||
background 0.12s,
|
||||
opacity 0.12s,
|
||||
box-shadow 0.12s;
|
||||
}
|
||||
.persona-btn.active {
|
||||
background: var(--panel);
|
||||
color: var(--ink);
|
||||
box-shadow: inset 0 0 0 1px var(--hair-2);
|
||||
font-weight: 600;
|
||||
}
|
||||
.persona-btn--coord.active {
|
||||
background: color-mix(in srgb, var(--accent) 15%, transparent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.persona-btn--coord.active .persona-led {
|
||||
background: var(--accent);
|
||||
opacity: 1;
|
||||
box-shadow: 0 0 6px var(--accent-glow-strong);
|
||||
}
|
||||
.persona-btn--int.active {
|
||||
background: color-mix(in srgb, var(--cyan) 15%, transparent);
|
||||
color: var(--cyan);
|
||||
}
|
||||
.persona-btn--int.active .persona-led {
|
||||
background: var(--cyan);
|
||||
opacity: 1;
|
||||
box-shadow: 0 0 6px var(--cyan-glow);
|
||||
}
|
||||
/* persona tag in the saved-sessions table — shares the rail chip base
|
||||
(.row .tag above); only no-wrap + the INT colour differ. */
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1569,141 +1569,6 @@ function _refreshConsentBadge() {
|
||||
* Render the action card for an MCP error envelope. Mirrors the
|
||||
* media-embed pattern: visible card on top, collapsible raw JSON below.
|
||||
*/
|
||||
// ---------------------------------------------------------------------------
|
||||
// HLS lazy-loader (follows the mermaid.js lazy-load pattern in
|
||||
// /shared/renderer.js)
|
||||
// ---------------------------------------------------------------------------
|
||||
let _hlsState = "idle";
|
||||
let _hlsQueue = [];
|
||||
|
||||
function _loadHls(callback) {
|
||||
if (_hlsState === "ready") {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
_hlsQueue.push(callback);
|
||||
if (_hlsState === "loading") return;
|
||||
_hlsState = "loading";
|
||||
const script = document.createElement("script");
|
||||
script.src = "/shared/hls-1.6.16/hls.min.js";
|
||||
script.onload = function () {
|
||||
_hlsState = "ready";
|
||||
const q = _hlsQueue;
|
||||
_hlsQueue = [];
|
||||
for (let i = 0; i < q.length; i++) q[i]();
|
||||
};
|
||||
script.onerror = function () {
|
||||
_hlsState = "idle";
|
||||
const q = _hlsQueue;
|
||||
_hlsQueue = [];
|
||||
// Fall through — _activatePlayer will use stream_url since Hls is undefined
|
||||
for (let i = 0; i < q.length; i++) q[i]();
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
function _isHlsUrl(url) {
|
||||
return typeof url === "string" && /\.m3u8(\?|$)/i.test(url);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Click-to-play delegated handler (follows img-placeholder pattern)
|
||||
// ---------------------------------------------------------------------------
|
||||
function _activatePlayer(btn) {
|
||||
const url = btn.dataset.streamUrl;
|
||||
const hlsUrl = btn.dataset.hlsUrl;
|
||||
const isAudio = btn.dataset.audioOnly === "true";
|
||||
const directStream = btn.dataset.directStream === "true";
|
||||
|
||||
const player = document.createElement(isAudio ? "audio" : "video");
|
||||
player.controls = true;
|
||||
player.autoplay = true;
|
||||
player.className = "media-player";
|
||||
|
||||
// Prefer direct stream when the source supports it; fall back to HLS
|
||||
// only when transcoding is needed.
|
||||
if (directStream && url) {
|
||||
player.src = url;
|
||||
} else if (
|
||||
hlsUrl &&
|
||||
!isAudio &&
|
||||
typeof Hls !== "undefined" &&
|
||||
Hls.isSupported()
|
||||
) {
|
||||
const hls = new Hls();
|
||||
hls.loadSource(hlsUrl);
|
||||
hls.attachMedia(player);
|
||||
} else if (
|
||||
hlsUrl &&
|
||||
!isAudio &&
|
||||
player.canPlayType("application/vnd.apple.mpegurl")
|
||||
) {
|
||||
player.src = hlsUrl;
|
||||
} else {
|
||||
player.src = url;
|
||||
}
|
||||
|
||||
player.addEventListener("error", function () {
|
||||
const card = player.closest(".media-embed");
|
||||
const titleEl = card ? card.querySelector(".media-card-title") : null;
|
||||
const label = titleEl ? ": " + titleEl.textContent : "";
|
||||
|
||||
const err = document.createElement("div");
|
||||
err.className = "media-player-error";
|
||||
err.setAttribute("role", "alert");
|
||||
err.textContent = "Failed to load stream" + label;
|
||||
|
||||
const retry = document.createElement("button");
|
||||
retry.className = "media-play-btn";
|
||||
retry.type = "button";
|
||||
retry.dataset.streamUrl = url;
|
||||
retry.dataset.hlsUrl = hlsUrl || "";
|
||||
retry.dataset.audioOnly = String(isAudio);
|
||||
retry.dataset.directStream = String(directStream);
|
||||
retry.setAttribute("aria-label", "Retry" + label);
|
||||
retry.appendChild(document.createTextNode("\u25b6 Retry"));
|
||||
|
||||
const container = document.createElement("div");
|
||||
container.appendChild(err);
|
||||
container.appendChild(retry);
|
||||
player.replaceWith(container);
|
||||
});
|
||||
|
||||
btn.replaceWith(player);
|
||||
}
|
||||
|
||||
document.addEventListener("click", function (e) {
|
||||
const btn = e.target.closest(".media-play-btn");
|
||||
if (!btn) return;
|
||||
e.preventDefault();
|
||||
btn.disabled = true;
|
||||
const labelEl = btn.querySelector("span:last-child");
|
||||
if (labelEl) {
|
||||
labelEl.textContent = "Loading\u2026";
|
||||
} else {
|
||||
btn.textContent = "\u25b6 Loading\u2026";
|
||||
}
|
||||
|
||||
const hlsUrl = btn.dataset.hlsUrl;
|
||||
const isAudio = btn.dataset.audioOnly === "true";
|
||||
|
||||
// If HLS URL present and not audio, ensure hls.js is loaded first
|
||||
if (hlsUrl && !isAudio && _isHlsUrl(hlsUrl)) {
|
||||
_loadHls(function () {
|
||||
_activatePlayer(btn);
|
||||
});
|
||||
} else {
|
||||
_activatePlayer(btn);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", function (e) {
|
||||
if (e.key !== "Enter") return;
|
||||
const btn = e.target.closest(".media-play-btn");
|
||||
if (!btn) return;
|
||||
btn.click();
|
||||
});
|
||||
|
||||
function _announce(text) {
|
||||
const el = document.getElementById("toast");
|
||||
if (!el) return;
|
||||
|
||||
Reference in New Issue
Block a user