Compare commits

..

6 Commits

Author SHA1 Message Date
Patrick Buckley 273d547f4e chore: bump version to 1.5.7 2026-05-04 03:04:09 -07:00
Patrick Buckley bbb404c363 feat(console): inline node picker replaces back-to-console banner (#475)
* feat(console): inline node picker replaces back-to-console banner

Drops the 32px banner the console proxy used to inject above proxied
server-UI pages and replaces it with an inline node-id pill in the
existing #ui-header.  Click the pill to open a dropdown that lists
healthy nodes (health dot, ws count, reachable/degraded/unreachable
text) plus a top-row link back to the console.

Reuses the .ws-tab-dropdown shell from ui/static/style.css for
animation, shadow, theme override, and item layout, so the picker
visually matches the workstream-tab chevron menu it sits next to.
Keyboard nav (ArrowDown/Up/Home/End/Tab/Escape) mirrors the chevron
menu's handler with cross-reference comments at both sites.
Lazy-fetches /v1/api/cluster/nodes against the console origin
(bypassing the prefix shim) on first open.

Reclaims 32px of vertical space, consolidates three separate
"you're on node X via console" indicators into one, and turns the
wayfinding chrome into a real cluster-nav primitive.

* fix(console): address Copilot review on node picker

- Request /v1/api/cluster/nodes?limit=1000 (collector's hard cap)
  instead of relying on the default 100 — clusters with more than
  100 nodes were silently dropping rows from the picker.
- Hand off focus to the first menu item after the async fetch
  resolves: openMenu()'s deferred focus hook ran while only the
  skeleton was in the DOM, so first-open keyboard users were
  stranded on the trigger until they pressed an arrow key.
- Tab now closes the menu without preventDefault, so focus moves
  to the next focusable element on the first press (ARIA APG menu
  pattern).  Escape still preventDefault + returns to the pill.
- Cap pill max-width at 240px and ellipsize the id span; node ids
  are accepted up to 256 chars upstream and could otherwise push
  the title and right-side controls off the appbar.  Pill carries
  a title attribute so the full id is still legible on hover.
2026-05-04 02:59:35 -07:00
Patrick Buckley 072113f7ca fix(session): properly inject queued user messages mid-loop (#474)
* fix(session): properly inject queued user messages mid-loop

Two queued-user-message bugs in ``ChatSession.send()``.

**Mid-tool-call: ``Unexpected role 'tool' after role 'user'`` on Mistral.**
The ``supports_tool_advisories`` capability flag (default False for
unknown openai-compatible models) routed cap-off providers down a
short-circuit branch in ``_collect_advisories`` that called
``_flush_queued_messages`` directly. That appended a ``user`` turn
between ``assistant(tool_calls)`` and ``tool``, which mistral-common's
``_validate_message_order`` rejects with a 400.

Drop the flag. All providers now run the unified path: queued user
messages become ``UserInterjection`` advisories that ride inside the
tool result envelope via ``wrap_tool_result``, splicing
``<system-reminder>`` text into the tool message's content. Role
sequence stays ``assistant → tool``. Live-confirmed on Mistral
medium and Qwen3 — both correctly distinguish system-reminder from
tool stdout in their reasoning.

**Mid-stream: queued message orphaned until next user send.**
After a no-tool assistant turn, ``_flush_queued_messages`` would
append the queued user message to history and the loop would
``break``, leaving the message at the tail of history with no
model response. Visible as "two sends to get one reply".

``_flush_queued_messages`` now returns ``bool``. The no-tool branch
``continue``s on drain instead of ``break``ing, so the model gets a
turn over the extended history.

Tests:
- ``test_collect_advisories_drains_text_queued_messages_to_persistent``
  pins the unified-path drain (text-only queue → ``UserInterjection``,
  no separate user turn appended to ``self.messages``).
- ``test_send_continues_when_messages_queued_during_streaming`` pins
  the loop-continue behavior (fails with 1 stream call pre-fix,
  passes with 2 post-fix).

* fix(session,ui): reject queued attachments + paperclip busy state

Copilot pointed out that the attachment-bearing branch in
``_collect_advisories`` had the same role-ordering bug as the
text-only path that 802658f fixed: an attachment-bearing queued
item would still call ``_append_user_turn`` mid-tool-call,
injecting ``user`` between ``assistant(tool_calls)`` and ``tool``.

Pragmatic fix: don't allow attachments to be queued at all.

**Backend.** ``ChatSession.queue_message`` raises a new
``AttachmentsNotQueueableError`` when called with non-empty
``attachment_ids``. The interactive ``/send`` route catches it,
releases reservations via the existing ``_release_reservation_on_fail``
hook, and surfaces ``status: "attachments_busy"`` to the caller
with the IDs in ``dropped_attachment_ids``. The coord adapter
mirrors the cleanup (releases the soft-locked reservation taken
for ``_send_id``) so the create-with-attachments path can't leak.

Now that the queue can never carry attachments, the per-item
``att_ids`` slot is gone:

- Queue tuple slimmed ``(cleaned, priority, att_ids)`` →
  ``(cleaned, priority)``.
- ``_flush_queued_messages`` collapses to a single combined-text
  user turn (no attachment branch).
- ``_collect_advisories`` queue-drain pushes ``UserInterjection``
  advisories only (no ``attachment_items`` list).
- ``dequeue_message`` no longer unreserves (queue can't reserve).
- ``_resolve_attachment_ids`` had no remaining production callers
  and is deleted along with the tests that exercised it in
  isolation.

**Frontend.** ``Composer.setBusy`` disables the paperclip whenever
busy (regardless of ``queueWhileBusy``) — text still queues,
attachments don't. ``chat.css`` gains a ``.composer-attach:disabled``
rule (mirrors the existing ``.composer-send:disabled`` treatment)
so the affordance actually looks unclickable instead of falling
through to the UA default. ``title`` and ``aria-label`` are kept in
sync for AT users (WCAG 4.1.2).

Both interactive and coordinator UIs handle the new
``attachments_busy`` response with a chat-surface error bubble:

> Attachments can't be sent while the assistant is working.
> Send a text-only message now, or wait and resend with attachments.

Chips stay in the composer so the user can retry once idle.

**Tests.** Replaced the now-impossible ``TestQueuedWithAttachments``
class with a rejection-coverage class. Rewrote the
``_queue_with_attachment`` route-test fixture to reserve directly
via ``reserve_attachments`` (the queue path no longer reaches the
reserved state). Added a route-level test for the new
``attachments_busy`` contract.
2026-05-04 02:59:35 -07:00
Patrick Buckley 7f1b0acf7a Bound search tool output against pathological inputs (#473)
* Bound search tool output against pathological inputs

Replaces the per-line truncation with a fully bounded pipeline so the
search tool can no longer overflow the LLM context — or OOM the parent —
on minified bundles, multi-GB JSONL records, or huge result sets.

Backend:
- Prefer ripgrep when on PATH; grep is the fallback. Detection is
  cached via functools.cache.
- ripgrep flags do most of the bounding natively: --max-columns 1024
  + --max-columns-preview, --max-filesize 10M, --max-count 100,
  --no-config, --no-messages, plus negative globs for the same
  noisy directories grep has been excluding.
- ripgrep added to the Dockerfile.

Streaming subprocess (_search_capture):
- subprocess.Popen with a streaming, byte-capped stdout read (4 MB).
  Defends against single-line files (training data, minified bundles)
  that would have OOM'd the previous subprocess.run capture.
- threading.Timer watchdog enforces tool_timeout even when the
  pipe read is blocked in the kernel — proc.wait(timeout=…) alone
  was insufficient because the read sat ahead of it.
- Stderr drained in a daemon thread to avoid pipe-deadlock when the
  child writes to stderr while we're still reading stdout. Cap on
  captured stderr keeps a hostile child from growing the buffer.

Tier-based formatter (_format_search_results):
- Tier 1: full path:line:content output, stream-emitted with a
  running-cost short-circuit so we never materialize past the budget.
- Tier 2: K samples per file with overflow notes; K is computed
  analytically from budget / file_count / avg-line-length so we hit
  the right ladder rung in a single pass.
- Tier 3: per-file counts only, also budget-bounded with a tail line
  reporting the omitted files. Sorted by descending count.
- Total output budget (32 KB) is well under tool_truncation, so the
  head+tail _truncate_output strategy never silently drops middle
  files in a search result.

Argument injection fix:
- The ripgrep arg list was missing the `--` separator that the grep
  branch already had. With auto_approve on the search tool, that was
  exploitable: path='--pre=COMMAND' would have made ripgrep run the
  script as a per-file preprocessor and surface its stdout. Added
  `--` and a regression test.

State-machine cleanup in _exec_search:
- rc < 0 (signal-killed by something other than us) now surfaces a
  dedicated 'killed by signal N' message instead of being parsed as
  success.
- capped + zero parsed records (e.g. one multi-MB line with no \n)
  now returns a dedicated byte-cap message instead of the malformed-
  output message that previously masked the real cause.
- _report_tool_result descriptions now match the returned payload
  (no more 'no matches' tag on a 'malformed' payload).

Defence-in-depth on env scrub:
- RIPGREP_CONFIG_PATH, GIT_CONFIG, GIT_CONFIG_GLOBAL, GIT_CONFIG_SYSTEM
  added to _EXPLICIT_SCRUB. We pass --no-config on the rg CLI today,
  but if a future caller forgets the flag, an attacker who can set
  one of these env vars could plant a config containing --pre=… and
  recreate the same RCE shape.

Tests:
- TestSearchLineTruncation rewritten to mock _search_capture instead
  of subprocess.run (the previous tests passed ChatSession kwargs
  that no longer satisfy the constructor).
- TestSearchBackendSelection covers rg/grep detection and arg
  construction, including the --pre flag-injection regression.
- TestSearchOutputBudget exercises Tier 1/2/3 directly.
- TestSearchCaptureStreaming spawns real Python subprocess writers
  to exercise the byte-cap trim, mega-line-no-newline edge case, the
  watchdog timeout when the child writes nothing, and the stderr
  drain under load.
- test_env_scrub picks up the new tool-config keys.

* Address Copilot review on #473

- Budget the Tier 2/3 header up front so the formatter's emission stays
  strictly within _SEARCH_OUTPUT_BUDGET. Previously the fit checks only
  counted body bytes, letting the final string overflow by ~120 chars
  (header + separator) and triggering _truncate_output's head+tail
  dropout — exactly the shape this code was trying to avoid.
- Restore the (5, 3, 1) ladder in Tier 2: the analytical K from perf-2
  is kept as a starting estimate, but if that K's actual emission
  doesn't fit (the estimate ignores the header and overweights shared-
  path compression) we step down through the ladder before falling
  through to Tier 3. The previous one-shot K could collapse to counts-
  only when 3/file or 1/file would have fit.
- Only normalise rc to 0 in the capped-output path when rc < 0 (our
  SIGKILL). There's a narrow race where the child can exit naturally
  between our read and our kill; preserving a non-negative rc means
  rg's rc=2 ('matches found but some files had errors') no longer
  silently turns into a clean success when the byte cap also fires.
- Clarify _MAX_SEARCH_LINE_LENGTH doc: the cap applies to the content
  portion (after path:lineno:), not the whole emitted line.
- Add explanatory comments on the two intentional `except Exception:
  pass` blocks in _search_capture (stderr drain, pipe close in the
  cleanup finally) so static analysis and future readers can see the
  silence is deliberate.
- Tighten the budget tests: now assert strict `<= _SEARCH_OUTPUT_BUDGET`
  instead of the +512-char slack that was masking the header overflow.
- New regression tests:
  - Tier 2 ladder step-down (K=5 over budget, K=3 fits, no Tier 3 fall-through)
  - capped + rc=2 surfaces stderr instead of being normalised to success
  - capped + rc<0 (our SIGKILL) flows through as a partial-result success

* chore(search): post-review cleanup

Follow-up to the Copilot-review fixes in 39d2aa2 — these are all small
quality items (no behaviour change, no new tests).

- q-1: collapse the Tier 2 candidates filter to a single expression.
  Drops the redundant inner ``max(estimated_k, 1)`` and the unreachable
  ``if not candidates`` branch (the ladder ends in 1 and ``estimated_k``
  is already floored at 1, so the comprehension always yields ≥ ``[1]``).
  ``or [...]`` is kept as defence against future ladder changes.
- q-2: update _format_search_results docstring to match the new ladder
  semantics (analytical seed → step down through (5, 3, 1) from the
  highest rung ≤ the estimate). The previous wording suggested every
  Tier 2 attempt started at 5.
- q-3: combine the two ``from turnstone.core.session import ...``
  statements in test_tier2_steps_down_ladder_before_falling_to_tier3
  into a single top-of-function import (matches the surrounding tests).
- q-4: shorten the explanatory comments on the two best-effort cleanup
  paths in _search_capture to one line each. Both sites now read with
  the same shape ("# best-effort: pipe may be torn down by ...").
- q-5: trim the _MAX_SEARCH_LINE_LENGTH comment from 7 lines back to 3.
  Keeps the load-bearing semantic (cap is on the content portion only)
  and the pathological-line defence; drops the paths-aren't-bounded
  parenthetical, which was background reading rather than WHY.
2026-05-04 02:59:35 -07:00
renovate[bot] 6904bd8f39 chore(deps): lock file maintenance 2026-05-04 02:59:35 -07:00
renovate[bot] 4d677d1ebf chore(deps): update github actions 2026-05-04 02:59:35 -07:00
22 changed files with 1839 additions and 620 deletions
+4 -4
View File
@@ -47,9 +47,9 @@ jobs:
# explicit setup, that suite silently skips if the runner
# image happens not to ship Node, masking regressions in
# the browser-side renderer.
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: "20"
node-version: "24"
- run: pip install -e ".[test]"
- run: pytest tests/ -m "not live" --cov=turnstone --cov-report=term-missing --cov-report=xml -q
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
@@ -79,9 +79,9 @@ jobs:
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.14"
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: "20"
node-version: "24"
- run: pip install -e ".[test,postgres]"
- run: pytest tests/ -m "not live" --storage-backend=postgresql -q
env:
+5 -2
View File
@@ -13,9 +13,12 @@ COPY --from=ghcr.io/astral-sh/uv:0.11.8 /uv /usr/local/bin/uv
# Remove the slim image's man page exclusion so man-db has actual content
RUN rm -f /etc/dpkg/dpkg.cfg.d/docker
# System dependencies: psycopg (libpq5), developer tooling for agent workflows
# System dependencies: psycopg (libpq5), developer tooling for agent workflows.
# ripgrep is the preferred backend for the search tool — natively bounds
# per-line, per-file, and per-filesize so pathological inputs (minified
# bundles, training-data JSONL with multi-MB single records) can't OOM us.
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends \
libpq5 git curl jq man-db manpages procps file \
libpq5 git curl jq man-db manpages procps file ripgrep \
&& rm -rf /var/lib/apt/lists/*
# Node.js LTS (for npx-based MCP servers like @modelcontextprotocol/server-github)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "turnstone"
version = "1.5.6"
version = "1.5.7"
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
readme = "README.md"
license = "BUSL-1.1"
+12 -12
View File
@@ -373,9 +373,9 @@
"license": "MIT"
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
"integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -902,9 +902,9 @@
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"dev": true,
"funding": [
{
@@ -959,9 +959,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.12",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz",
"integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==",
"version": "8.5.13",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz",
"integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==",
"dev": true,
"funding": [
{
@@ -1060,9 +1060,9 @@
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz",
"integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==",
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz",
"integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==",
"dev": true,
"license": "MIT",
"engines": {
+76 -19
View File
@@ -1512,11 +1512,62 @@ class TestProxyRewriting:
assert "window.fetch" in _JS_PROXY_SHIM
assert "window.EventSource" in _JS_PROXY_SHIM
def test_console_banner_contains_placeholder(self):
from turnstone.console.server import _CONSOLE_BANNER_TEMPLATE
def test_js_shim_carries_node_id_placeholder(self):
"""The picker reads the current node_id from the shim's _nodeId
closure variable; the placeholder must be present and substitutable."""
from turnstone.console.server import _JS_PROXY_SHIM
assert "NODE_ID_PLACEHOLDER" in _CONSOLE_BANNER_TEMPLATE
assert "Console" in _CONSOLE_BANNER_TEMPLATE
assert "NODE_ID_PLACEHOLDER" in _JS_PROXY_SHIM
replaced = _JS_PROXY_SHIM.replace("NODE_ID_PLACEHOLDER", "node-a")
assert "node-a" in replaced
assert "NODE_ID_PLACEHOLDER" not in replaced
def test_js_shim_includes_picker_pieces(self):
"""Picker logic ships in the same IIFE as the prefix shim — verify
the moving parts are present so a future refactor doesn't silently
drop them. /v1/api/cluster/nodes is the lazy-fetch target;
#ui-header is the DOM anchor; console-node-pill is the trigger
class; ws-tab-dropdown is the menu shell we share with the
workstream chevron menu (style + behaviour parity); ArrowDown is
the keyboard-nav primitive that disambiguates this from a plain
click-only menu."""
from turnstone.console.server import _JS_PROXY_SHIM
# limit=1000 matches the collector's hard cap; without it the
# picker would silently drop nodes past the 100-default in
# clusters with >100 nodes.
assert "/v1/api/cluster/nodes?limit=1000" in _JS_PROXY_SHIM
assert "ui-header" in _JS_PROXY_SHIM
assert "console-node-pill" in _JS_PROXY_SHIM
assert "ws-tab-dropdown" in _JS_PROXY_SHIM
assert "ArrowDown" in _JS_PROXY_SHIM
assert "DOMContentLoaded" in _JS_PROXY_SHIM
def test_proxy_style_drops_banner_styles(self):
"""The legacy banner CSS classes (.console-banner, .ts-header-back-link
offsets, .dashboard-overlay top:32px hack) should be gone — the new
picker lives inside #ui-header and doesn't need overlay offsets."""
from turnstone.console.server import _CONSOLE_PROXY_STYLE
assert ".console-banner" not in _CONSOLE_PROXY_STYLE
assert "dashboard-overlay" not in _CONSOLE_PROXY_STYLE
assert ".console-node-pill" in _CONSOLE_PROXY_STYLE
assert ".console-node-menu" in _CONSOLE_PROXY_STYLE
def test_proxy_style_uses_canonical_degraded_color(self):
"""Degraded health dot must use --accent (the canonical "needs
attention" token used by the cluster-overview node table at
console/static/style.css:548) and not --yellow. Yellow is reserved
for the dash-state attention dot, a stronger signal."""
from turnstone.console.server import _CONSOLE_PROXY_STYLE
assert "console-node-menu-item-dot--degraded" in _CONSOLE_PROXY_STYLE
# The degraded rule sits on its own line; assert it uses --accent
# by checking the CSS substring has --accent and not --yellow.
idx = _CONSOLE_PROXY_STYLE.find("console-node-menu-item-dot--degraded")
rule = _CONSOLE_PROXY_STYLE[idx : idx + 200]
assert "var(--accent)" in rule
assert "var(--yellow)" not in rule
def test_html_rewriting_changes_static_paths(self):
"""Simulate the proxy_index rewriting logic."""
@@ -1533,16 +1584,24 @@ class TestProxyRewriting:
assert 'href="/static/' not in rewritten
assert 'src="/static/' not in rewritten
def test_banner_injection_after_body(self):
"""Simulate the banner injection logic."""
from turnstone.console.server import _CONSOLE_BANNER_TEMPLATE
def test_shim_injection_after_body(self):
"""Simulate the proxy shim injection — the shim ships the node-id
and prefix as JS literals and renders the picker at runtime, so
we assert the substituted JS literals land in the page."""
from turnstone.console.server import _CONSOLE_PROXY_STYLE, _JS_PROXY_SHIM
sample_html = "<html><body><div>content</div></body></html>"
banner = _CONSOLE_BANNER_TEMPLATE.replace("NODE_ID_PLACEHOLDER", "node-a")
result = sample_html.replace("<body>", "<body>" + banner, 1)
assert "node-a" in result
assert "Console" in result
assert result.startswith("<html><body><div")
prefix = "/node/node-a"
shim_js = _JS_PROXY_SHIM.replace('"PREFIX_PLACEHOLDER"', json.dumps(prefix)).replace(
'"NODE_ID_PLACEHOLDER"', json.dumps("node-a")
)
injection = _CONSOLE_PROXY_STYLE + "<script>" + shim_js + "</script>"
result = sample_html.replace("<body>", "<body>" + injection, 1)
assert '"node-a"' in result
assert '"/node/node-a"' in result
assert "PREFIX_PLACEHOLDER" not in result
assert "NODE_ID_PLACEHOLDER" not in result
assert result.startswith("<html><body><style>")
# ---------------------------------------------------------------------------
@@ -1777,17 +1836,15 @@ class TestProxySharedStatic:
def test_proxy_shim_injected_in_html(self):
"""Verify shim is injected as inline script in proxied HTML."""
from turnstone.console.server import _CONSOLE_BANNER_TEMPLATE, _JS_PROXY_SHIM
from turnstone.console.server import _JS_PROXY_SHIM
sample_html = "<html><body><div>content</div></body></html>"
prefix = "/node/test-node"
banner = _CONSOLE_BANNER_TEMPLATE.replace("NODE_ID_PLACEHOLDER", "test-node")
shim = (
"<script>"
+ _JS_PROXY_SHIM.replace('"PREFIX_PLACEHOLDER"', json.dumps(prefix))
+ "</script>"
shim_js = _JS_PROXY_SHIM.replace('"PREFIX_PLACEHOLDER"', json.dumps(prefix)).replace(
'"NODE_ID_PLACEHOLDER"', json.dumps("test-node")
)
result = sample_html.replace("<body>", "<body>" + banner + shim, 1)
shim = "<script>" + shim_js + "</script>"
result = sample_html.replace("<body>", "<body>" + shim, 1)
assert "<script>" in result
assert "/node/test-node" in result
assert "window.fetch" in result
+10
View File
@@ -15,6 +15,16 @@ class TestIsSecret:
assert _is_secret("TURNSTONE_JWT_SECRET") is True
assert _is_secret("AWS_SECRET_ACCESS_KEY") is True
def test_tool_config_paths_scrubbed(self):
"""Tool-config env vars whose target files load executable
directives must be scrubbed even though they don't match a
secret-suffix pattern. Defence-in-depth alongside on-CLI
``--no-config`` for ripgrep and friends."""
assert _is_secret("RIPGREP_CONFIG_PATH") is True
assert _is_secret("GIT_CONFIG") is True
assert _is_secret("GIT_CONFIG_GLOBAL") is True
assert _is_secret("GIT_CONFIG_SYSTEM") is True
def test_suffix_matching(self):
assert _is_secret("MY_CUSTOM_API_KEY") is True
assert _is_secret("DB_PASSWORD") is True
+31 -29
View File
@@ -10,6 +10,7 @@ from __future__ import annotations
import queue
import threading
import uuid
from unittest.mock import MagicMock
import pytest
@@ -707,22 +708,26 @@ class TestQueuedAttachmentReservation:
mgr.get.return_value = ws
return ws, session
def _queue_with_attachment(self, client, mgr, ws_id: str, filename: str = "q.md"):
def _reserve_attachment(self, client, mgr, ws_id: str, filename: str = "q.md"):
"""Set up a reserved attachment for the busy-worker tests below.
The queue-with-attachments path was removed (queued user turns
can't carry attachments — see ``AttachmentsNotQueueableError``),
so the tests reserve directly via ``reserve_attachments`` to
produce the same on-disk state without going through the
rejected route path.
"""
from turnstone.core.memory import reserve_attachments
aid = _upload(client, ws_id, "userA", filename, b"Q", "text/markdown")
ws, session = self._wire_busy_ws(mgr, ws_id)
resp = client.post(
f"/v1/api/workstreams/{ws_id}/send",
json={"message": "queued", "attachment_ids": [aid]},
headers=_auth("userA"),
)
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "queued"
return aid, body["msg_id"], session
msg_id = uuid.uuid4().hex
reserve_attachments([aid], msg_id, ws_id, "userA")
return aid, msg_id, session
def test_reserved_attachment_hidden_from_pending_listing(self, app_client):
client, mgr = app_client
aid, _mid, _session = self._queue_with_attachment(client, mgr, "ws-A")
aid, _mid, _session = self._reserve_attachment(client, mgr, "ws-A")
resp = client.get("/v1/api/workstreams/ws-A/attachments", headers=_auth("userA"))
# Reserved attachment is not in the pending listing
ids = [a["attachment_id"] for a in resp.json()["attachments"]]
@@ -730,7 +735,7 @@ class TestQueuedAttachmentReservation:
def test_reserved_attachment_cannot_be_deleted(self, app_client):
client, mgr = app_client
aid, _mid, _session = self._queue_with_attachment(client, mgr, "ws-A")
aid, _mid, _session = self._reserve_attachment(client, mgr, "ws-A")
resp = client.delete(
f"/v1/api/workstreams/ws-A/attachments/{aid}",
headers=_auth("userA"),
@@ -745,7 +750,7 @@ class TestQueuedAttachmentReservation:
def test_reserved_attachment_not_auto_consumed_by_later_send(self, app_client):
client, mgr = app_client
aid, _mid, session = self._queue_with_attachment(client, mgr, "ws-A")
aid, _mid, session = self._reserve_attachment(client, mgr, "ws-A")
# Swap the busy worker for an idle one and capture the next
# session.send call so we can assert on its attachment list.
@@ -781,7 +786,7 @@ class TestQueuedAttachmentReservation:
def test_reserved_attachment_rejected_in_explicit_ids(self, app_client):
client, mgr = app_client
aid, _mid, session = self._queue_with_attachment(client, mgr, "ws-A")
aid, _mid, session = self._reserve_attachment(client, mgr, "ws-A")
captured: dict = {}
@@ -813,29 +818,26 @@ class TestQueuedAttachmentReservation:
if atts is not None:
assert aid not in [a.attachment_id for a in atts]
def test_dequeue_releases_reservation(self, app_client):
def test_send_with_attachments_to_busy_worker_returns_attachments_busy(self, app_client):
"""An attempt to attach mid-tool-call returns ``attachments_busy``;
attachments stay pending so the client can retry once idle."""
client, mgr = app_client
aid, mid, session = self._queue_with_attachment(client, mgr, "ws-A")
# Cancel the queued message — DELETE /api/send with msg_id
resp = client.request(
"DELETE",
aid = _upload(client, "ws-A", "userA", "x.md", b"X", "text/markdown")
self._wire_busy_ws(mgr, "ws-A")
resp = client.post(
"/v1/api/workstreams/ws-A/send",
json={"msg_id": mid},
json={"message": "with file", "attachment_ids": [aid]},
headers=_auth("userA"),
)
assert resp.status_code == 200
assert resp.json().get("status") == "removed"
# Attachment is back to pending — visible + deletable
body = resp.json()
assert body["status"] == "attachments_busy"
assert body["attached_ids"] == []
assert body["dropped_attachment_ids"] == [aid]
# Reservation released — attachment is still pending and visible.
resp = client.get("/v1/api/workstreams/ws-A/attachments", headers=_auth("userA"))
ids = [a["attachment_id"] for a in resp.json()["attachments"]]
assert aid in ids
resp = client.delete(
f"/v1/api/workstreams/ws-A/attachments/{aid}",
headers=_auth("userA"),
)
assert resp.status_code == 200
class TestReserveThenDispatchRace:
+546 -36
View File
@@ -3,8 +3,11 @@
import base64
import contextlib
import json
import subprocess
from unittest.mock import MagicMock, patch
import pytest
from turnstone.core.session import _IMAGE_EXTENSIONS, _IMAGE_SIZE_CAP, ChatSession
@@ -83,6 +86,25 @@ def _make_session(
return ChatSession(**defaults)
def _run_exec_search(session, capture_return):
"""Patch ``_search_capture`` to ``capture_return`` and run ``_exec_search``.
Returns the formatted output string. The fixed call args
(``call_id``/``pattern``/``path``) are deliberately uniform across the
line-truncation tests — only the captured stdout/rc/stderr/capped tuple
varies between cases.
"""
with patch.object(session, "_search_capture", return_value=capture_return):
_, output = session._exec_search(
{
"call_id": "test_call",
"pattern": "test_pattern",
"path": "/workspace/turnstone",
}
)
return output
class TestChatSessionConstruction:
def test_system_messages_created(self, tmp_db):
session = _make_session()
@@ -1984,13 +2006,6 @@ class TestMetacognitiveBuffers:
assert session._pending_user_advisories == [("correction", "USER_NUDGE_MARK")]
assert session._pending_tool_advisories == [("tool_error", "TOOL_NUDGE_MARK")]
def _patch_caps(self, session, *, supports_tool_advisories: bool):
"""Force capability flag for advisory-aware tests."""
caps = MagicMock()
caps.supports_tool_advisories = supports_tool_advisories
with patch.object(session, "_get_capabilities", return_value=caps):
return caps
def test_collect_advisories_drains_tool_buffer_on_last_result(self, tmp_db):
"""Tool-channel metacog reminders no longer ride the persistent
advisory list (which would write them into tool content via
@@ -2000,12 +2015,9 @@ class TestMetacognitiveBuffers:
channel."""
session = _make_session()
session._queue_tool_advisory("tool_error", "ALERT")
caps = MagicMock()
caps.supports_tool_advisories = True
with patch.object(session, "_get_capabilities", return_value=caps):
persistent, metacog = session._collect_advisories(
assessment=None, func_name="bash", is_last_in_batch=True
)
persistent, metacog = session._collect_advisories(
assessment=None, func_name="bash", is_last_in_batch=True
)
# Persistent list is empty (no guard / interjection here);
# MetacognitiveAdvisory does NOT appear among persistent
# advisories anymore.
@@ -2017,32 +2029,41 @@ class TestMetacognitiveBuffers:
def test_collect_advisories_holds_tool_buffer_until_last_result(self, tmp_db):
session = _make_session()
session._queue_tool_advisory("repeat", "STOP_REPEATING")
caps = MagicMock()
caps.supports_tool_advisories = True
with patch.object(session, "_get_capabilities", return_value=caps):
persistent, metacog = session._collect_advisories(
assessment=None, func_name="bash", is_last_in_batch=False
)
persistent, metacog = session._collect_advisories(
assessment=None, func_name="bash", is_last_in_batch=False
)
# Not yet drained — only fires on the last result.
assert persistent == []
assert metacog == []
assert len(session._pending_tool_advisories) == 1
def test_collect_advisories_drops_tool_buffer_when_caps_unsupported(self, tmp_db):
"""When the model can't parse advisory tags, drop the metacognitive
nudge silently rather than embedding raw XML the model will choke on."""
def test_collect_advisories_drains_text_queued_messages_to_persistent(self, tmp_db):
"""Text-only queued user messages drain into the ``persistent``
advisory list as ``UserInterjection`` on the last result of a
batch — they ride INSIDE the tool result envelope via
``wrap_tool_result`` rather than becoming a separate user turn
appended to ``self.messages`` (which would inject ``user``
between ``assistant(tool_calls)`` and ``tool`` and break role
validation on Mistral / mistral-common and similar strict
templates)."""
from turnstone.core.tool_advisory import UserInterjection
session = _make_session()
session._queue_tool_advisory("tool_error", "ALERT")
caps = MagicMock()
caps.supports_tool_advisories = False
with patch.object(session, "_get_capabilities", return_value=caps):
persistent, metacog = session._collect_advisories(
assessment=None, func_name="bash", is_last_in_batch=True
)
assert persistent == []
pre_count = len(session.messages)
session.queue_message("hows it going?", queue_msg_id="q1")
persistent, metacog = session._collect_advisories(
assessment=None, func_name="bash", is_last_in_batch=True
)
assert metacog == []
# And the buffer is cleared so no stale nudge sticks around.
assert session._pending_tool_advisories == []
assert len(persistent) == 1
assert isinstance(persistent[0], UserInterjection)
assert persistent[0].message == "hows it going?"
# Queue drained.
assert session._queued_messages == {}
# Crucially: NO separate user turn was appended to history —
# the message rides inside the tool envelope, preserving the
# `assistant(tool_calls) → tool` role sequence on the wire.
assert len(session.messages) == pre_count
def test_start_nudge_fires_through_send(self, tmp_db):
"""Pin the +1 count-shift invariant — `start` must still fire on the
@@ -2112,10 +2133,7 @@ class TestMetacognitiveBuffers:
session = _make_session()
session.ui = MagicMock()
session._queue_tool_advisory("tool_error", "alert")
caps = MagicMock()
caps.supports_tool_advisories = True
with patch.object(session, "_get_capabilities", return_value=caps):
session._collect_advisories(assessment=None, func_name="bash", is_last_in_batch=True)
session._collect_advisories(assessment=None, func_name="bash", is_last_in_batch=True)
info_lines = [call.args[0] for call in session.ui.on_info.call_args_list if call.args]
assert not any("metacognition: nudge injected" in line for line in info_lines), (
f"expected NO legacy ping, got {info_lines!r}"
@@ -2786,6 +2804,74 @@ class TestUserAdvisoryCancelClear:
session.send("user input")
assert session._pending_user_advisories == []
def test_send_continues_when_messages_queued_during_streaming(self, tmp_db):
"""A user message queued while the assistant is streaming a
non-tool response must trigger another model turn — not orphan
in history until the next user send.
Pre-fix bug: after the no-tool branch ran ``_flush_queued_messages``,
the loop ``break``-d unconditionally, leaving the queued user
message at the tail of ``self.messages`` with no model response.
The next outside ``send()`` would finally pick it up alongside
the new message — visible as the "two sends to get one reply"
symptom.
Fix: ``_flush_queued_messages`` returns whether anything drained;
the no-tool branch ``continue``-s when it did."""
session = _make_session()
# Suppress the auto-title daemon thread the no-tool branch
# would spawn — irrelevant to this test and would otherwise
# call the mocked client from a background thread.
session._title_generated = True
stream_calls = 0
def mock_create_stream(msgs):
nonlocal stream_calls
stream_calls += 1
if stream_calls == 1:
# Simulate a queued message arriving mid-stream — by the
# time the no-tool branch runs ``_flush_queued_messages``,
# this item is in the queue waiting to be drained.
session.queue_message("late arrival", queue_msg_id="q-late")
return iter([])
with (
patch.object(session, "_create_stream_with_retry", side_effect=mock_create_stream),
patch.object(
session,
"_stream_response",
return_value={"role": "assistant", "content": "ok"},
),
patch.object(session, "_full_messages", return_value=[]),
patch.object(session, "_update_token_table"),
patch.object(session, "_print_status_line"),
patch.object(session, "_emit_state"),
patch.object(session, "_visible_memory_count", return_value=0),
patch("turnstone.core.session.save_message"),
):
session.send("first message")
# Loop continued: a second stream call happened after the
# queued message drained into history. Pre-fix: 1 call.
assert stream_calls == 2, (
f"expected loop to continue after drain (2 stream calls); got {stream_calls}"
)
# The queued message landed in history before the second turn.
user_texts: list[str] = []
for m in session.messages:
if m.get("role") != "user":
continue
content = m.get("content")
if isinstance(content, str):
user_texts.append(content)
elif isinstance(content, list):
for part in content:
if isinstance(part, dict) and "text" in part:
user_texts.append(part["text"])
assert any("late arrival" in t for t in user_texts), (
f"queued message must appear in history; got user texts: {user_texts!r}"
)
class TestReminderSidechannelIsolation:
"""The side-channel design's load-bearing guarantee: any reader of
@@ -2891,3 +2977,427 @@ class TestSessionUIBaseToolReminderHook:
"tool_call_id": "call_abc123",
}
]
class TestSearchLineTruncation:
"""Tests for search tool line truncation to prevent context overflow."""
def test_search_truncates_long_lines_preserves_path(self):
"""Long lines are truncated but path:line: prefix is preserved for file counting."""
from turnstone.core.session import (
_MAX_SEARCH_LINE_LENGTH,
_SEARCH_LINE_MARGIN,
_SEARCH_TRUNCATION_SUFFIX,
)
# path:line:content where content is way over the cap+margin
long_content = "x" * 5000
stdout = f"turnstone/core/session.py:100:{long_content}\n".encode()
output = _run_exec_search(_make_session(), (stdout, 0, b"", False))
assert _SEARCH_TRUNCATION_SUFFIX in output
assert "turnstone/core/session.py" in output
# The *content portion* (after the 2nd colon) is what's bounded by
# the per-line cap; the path prefix is unbounded.
max_content_len = (
_MAX_SEARCH_LINE_LENGTH + len(_SEARCH_TRUNCATION_SUFFIX) + _SEARCH_LINE_MARGIN
)
for line in output.splitlines():
if "matches across" in line or not line.strip():
continue
parts = line.split(":", 2)
if len(parts) == 3:
assert len(parts[2]) <= max_content_len
def test_search_file_counting_with_truncated_lines(self):
"""File counting works correctly even with truncated lines."""
stdout = (
"turnstone/core/session.py:100:" + "x" * 5000 + "\n"
"turnstone/core/auth.py:50:normal line\n"
"turnstone/core/session.py:200:" + "y" * 3000 + "\n"
).encode()
output = _run_exec_search(_make_session(), (stdout, 0, b"", False))
assert "3 matches across 2 files" in output
assert "turnstone/core/session.py" in output
assert "turnstone/core/auth.py" in output
def test_search_drops_lines_without_colon(self):
"""Lines without any colon are dropped at the parsing step."""
from turnstone.core.session import _SEARCH_ALL_TRUNCATED_MSG
# No colon anywhere — parsed records list is empty.
stdout = ("turnstone/core/session.py" + "x" * 5000 + "\n").encode()
output = _run_exec_search(_make_session(), (stdout, 0, b"", False))
assert output == _SEARCH_ALL_TRUNCATED_MSG
def test_search_handles_single_colon_lines(self):
"""Lines with one colon and a non-numeric line-number portion are dropped."""
from turnstone.core.session import _SEARCH_ALL_TRUNCATED_MSG
# path:100xxxxx... — partition's lineno chunk has trailing junk, .isdigit() fails
stdout = ("turnstone/core/session.py:100" + "x" * 5000 + "\n").encode()
output = _run_exec_search(_make_session(), (stdout, 0, b"", False))
assert output == _SEARCH_ALL_TRUNCATED_MSG
def test_search_no_truncation_for_short_lines(self):
"""Short lines pass through unchanged."""
stdout = b"turnstone/core/session.py:100:short line\n"
output = _run_exec_search(_make_session(), (stdout, 0, b"", False))
assert "...[truncated" not in output
assert "short line" in output
def test_search_no_matches(self):
"""rc==1 (no matches) returns the friendly no-matches sentinel."""
output = _run_exec_search(_make_session(), (b"", 1, b"", False))
assert output == "(no matches)"
def test_search_error_propagates_stderr(self):
"""rc>1 surfaces stderr text, not a generic message, when stderr is non-empty."""
output = _run_exec_search(
_make_session(),
(b"", 2, b"grep: foo: No such file or directory\n", False),
)
assert "No such file or directory" in output
def test_search_capped_flag_in_output(self):
"""When raw stdout is byte-capped, results note the partial output."""
stdout = b"a/b.py:1:line1\na/b.py:2:line2\n"
output = _run_exec_search(_make_session(), (stdout, 0, b"", True))
assert "byte cap" in output or "capped" in output
def test_search_capped_preserves_nonzero_rc_error(self):
"""When the byte cap fires AND the child also returned a real
error rc (rg's rc=2 = 'matches with errors'), surface the error
instead of silently treating it as success. The capped→rc=0
normalisation should only apply to the SIGKILL we issued (rc<0).
"""
stdout = b"a/b.py:1:line1\n"
output = _run_exec_search(
_make_session(),
(stdout, 2, b"rg: some/file: Permission denied\n", True),
)
assert "Permission denied" in output
def test_search_capped_with_signal_kill_treated_as_success(self):
"""Capped output with rc<0 (our SIGKILL) flows through as a
successful partial result — the capped annotation in the output
signals incompleteness."""
stdout = b"a/b.py:1:line1\n"
output = _run_exec_search(_make_session(), (stdout, -9, b"", True))
assert "a/b.py:1:line1" in output
assert "byte cap" in output or "capped" in output
class TestSearchBackendSelection:
"""Tests for backend detection (rg vs grep) and arg construction."""
def test_detect_uses_rg_when_on_path(self):
from turnstone.core.session import _detect_search_backend
# Reset cache so the patch takes effect.
_detect_search_backend.cache_clear()
try:
with patch("turnstone.core.session.shutil.which", return_value="/usr/bin/rg"):
assert _detect_search_backend() == "rg"
finally:
_detect_search_backend.cache_clear()
def test_detect_falls_back_to_grep(self):
from turnstone.core.session import _detect_search_backend
_detect_search_backend.cache_clear()
try:
with patch("turnstone.core.session.shutil.which", return_value=None):
assert _detect_search_backend() == "grep"
finally:
_detect_search_backend.cache_clear()
def test_detect_caches_result(self):
from turnstone.core.session import _detect_search_backend
_detect_search_backend.cache_clear()
try:
with patch(
"turnstone.core.session.shutil.which", return_value="/usr/bin/rg"
) as mock_which:
_detect_search_backend()
_detect_search_backend()
_detect_search_backend()
assert mock_which.call_count == 1
finally:
_detect_search_backend.cache_clear()
def test_rg_args_include_size_and_column_caps(self):
from turnstone.core.session import (
_MAX_SEARCH_LINE_LENGTH,
_SEARCH_MAX_FILESIZE,
_build_search_args,
)
args = _build_search_args("foo", "/some/path", "rg")
assert args[0] == "rg"
# Per-line cap with preview marker (the load-bearing flag pair)
assert "--max-columns" in args
assert str(_MAX_SEARCH_LINE_LENGTH) in args
assert "--max-columns-preview" in args
# Per-file size guard against multi-MB JSONL records
assert "--max-filesize" in args
assert _SEARCH_MAX_FILESIZE in args
# Per-file match cap
assert "--max-count" in args
# ``-e <pattern>`` form so patterns starting with ``-`` are safe;
# ``--`` separator before the path so paths starting with ``-``
# (e.g. ``--pre=/tmp/x``) cannot be parsed as ripgrep flags.
assert "-e" in args
e_idx = args.index("-e")
assert args[e_idx + 1] == "foo"
assert "--" in args
sep = args.index("--")
assert args[sep + 1] == "/some/path"
assert args[-1] == "/some/path"
def test_rg_args_protect_path_from_flag_injection(self):
"""A ``path`` starting with ``-`` cannot inject ripgrep flags.
Regression test for an RCE vector: without the ``--`` separator,
``path="--pre=/tmp/x.sh"`` would have made ripgrep execute the
script as a per-file preprocessor and surface its stdout as
search results.
"""
from turnstone.core.session import _build_search_args
args = _build_search_args("foo", "--pre=/tmp/evil.sh", "rg")
assert "--" in args
sep = args.index("--")
assert args[sep + 1] == "--pre=/tmp/evil.sh"
# And the malicious path is the last token, not interspersed with flags.
assert args[-1] == "--pre=/tmp/evil.sh"
def test_grep_args_include_excludes_and_separator(self):
from turnstone.core.session import _build_search_args
args = _build_search_args("foo", "/some/path", "grep")
assert args[0] == "grep"
assert "-rn" in args
assert "-I" in args
assert "-E" in args
# Excludes for noisy build dirs
assert any(a == "--exclude-dir=node_modules" for a in args)
assert any(a == "--exclude-dir=.git" for a in args)
# ``--`` separator is what protects pattern-as-flag in grep
assert "--" in args
sep = args.index("--")
assert args[sep + 1] == "foo"
assert args[sep + 2] == "/some/path"
class TestSearchOutputBudget:
"""Tests for tier-based degradation when output exceeds the budget."""
def test_tier1_fits_full_output(self):
from turnstone.core.session import _format_search_results
records = [
("foo.py", "1", "small match"),
("bar.py", "2", "another match"),
("foo.py", "3", "third match"),
]
out = _format_search_results(records, capped=False)
assert "foo.py:1:small match" in out
assert "bar.py:2:another match" in out
assert "foo.py:3:third match" in out
assert "3 matches across 2 files" in out
def test_tier2_samples_when_over_budget(self):
"""Many matches per file → degrade to K samples per file with overflow notes."""
from turnstone.core.session import _SEARCH_OUTPUT_BUDGET, _format_search_results
# 3 files × 200 matches/file × ~80 chars/line ≈ 48 KB → over the 32 KB budget
records = []
line = "x" * 60
for f in ("a.py", "b.py", "c.py"):
for i in range(200):
records.append((f, str(i), line))
out = _format_search_results(records, capped=False)
# Should have collapsed to per-file samples + overflow note
assert "showing first" in out
assert "more in a.py" in out
assert "more in b.py" in out
assert "more in c.py" in out
# Strict: the formatter budgets for header + separator up front,
# so the final emission stays at or below ``_SEARCH_OUTPUT_BUDGET``
# without needing ``_truncate_output`` as a backstop.
assert len(out) <= _SEARCH_OUTPUT_BUDGET
def test_tier3_counts_only_when_too_many_files(self):
"""Thousands of files × matches → degrade to per-file counts."""
from turnstone.core.session import _SEARCH_OUTPUT_BUDGET, _format_search_results
records = []
# 2000 files × 50 matches × 80 chars = 8 MB; well past budget even at 1/file
line = "x" * 60
for f_idx in range(2000):
for i in range(50):
records.append((f"path/to/file_{f_idx:04}.py", str(i), line))
out = _format_search_results(records, capped=False)
assert "Counts only" in out
assert "path/to/file_0000.py: 50 matches" in out
assert len(out) <= _SEARCH_OUTPUT_BUDGET
def test_tier1_preserves_file_order(self):
"""Tier 1 emits files in insertion order (so first-seen file appears first)."""
from turnstone.core.session import _format_search_results
records = [
("z.py", "1", "first"),
("a.py", "2", "second"),
("z.py", "3", "third"),
]
out = _format_search_results(records, capped=False)
z_idx = out.index("z.py:1:")
a_idx = out.index("a.py:2:")
assert z_idx < a_idx, "first-seen file (z.py) should appear before later-seen (a.py)"
def test_capped_flag_propagates_to_summary(self):
from turnstone.core.session import _format_search_results
records = [("foo.py", "1", "match")]
out = _format_search_results(records, capped=True)
assert "byte cap" in out or "capped" in out
def test_tier2_steps_down_ladder_before_falling_to_tier3(self):
"""When the analytical K is too aggressive, Tier 2 must step
down the (5, 3, 1) ladder before falling through to Tier 3.
Regression test for the perf-2 → ladder-collapse bug.
"""
from turnstone.core.session import _SEARCH_OUTPUT_BUDGET, _format_search_results
# Tune so K=5 doesn't fit but a smaller K does. ~70 files with
# ~30 matches each at ~120 chars/line: K=5 emits ~42 KB (over
# the 32 KB budget); K=3 emits ~25 KB (fits).
records = []
line = "x" * 100
for f_idx in range(70):
for i in range(30):
records.append((f"src/file_{f_idx:02}.py", str(i), line))
out = _format_search_results(records, capped=False)
# Did NOT collapse to Tier 3.
assert "Counts only" not in out
# Used a smaller-than-5 K — the header reports the chosen K.
# We don't assert the exact K (the analytical estimate may pick
# 1, 3, or 4), but we DO assert it's a per-file-samples result.
assert "showing first" in out
# And that it stayed within budget.
assert len(out) <= _SEARCH_OUTPUT_BUDGET
class TestSearchCaptureStreaming:
"""Direct tests for ``_search_capture`` — the streaming subprocess
layer that backs ``_exec_search``. These tests do NOT mock subprocess;
they spawn small ``python -c`` writers so the byte-cap, last-newline
trim, and timeout paths actually execute in real OS processes.
"""
def test_byte_cap_trims_to_last_newline(self):
"""Writer emits >cap bytes of well-formed lines; capture caps and
trims to the last newline so the parser never sees a partial
trailing line."""
import sys
from turnstone.core.session import _SEARCH_RAW_BYTE_CAP
session = _make_session()
# Each line is "p:1:" + 1023 'x' chars + '\n' = 1028 bytes; emit
# enough lines to comfortably exceed the 4 MB cap.
line_count = (_SEARCH_RAW_BYTE_CAP // 1028) + 100
writer = (
"import sys\n"
f"line = 'p:1:' + ('x' * 1023) + '\\n'\n"
f"sys.stdout.buffer.write(line.encode() * {line_count})\n"
)
stdout, rc, stderr, capped = session._search_capture([sys.executable, "-c", writer])
assert capped is True
assert len(stdout) <= _SEARCH_RAW_BYTE_CAP
# Trim was applied — every parsed line is well-formed (no partial
# trailing line). The buffer is sliced at the last newline, which
# discards the (possibly partial) bytes after it.
lines = stdout.splitlines()
assert lines, "expected at least one complete line"
for raw in lines:
assert raw.startswith(b"p:1:")
assert len(raw) == 1027 # "p:1:" + 1023 x's, no trailing \n
def test_byte_cap_mega_line_no_newline(self):
"""A single multi-MB line with no newline is the worst-case input
(think a JSONL training record on one line). The cap fires and
``last_nl == -1`` skips the trim — _exec_search distinguishes
this from 'all malformed' via the dedicated byte-cap message."""
import sys
from turnstone.core.session import _SEARCH_RAW_BYTE_CAP
session = _make_session()
# 5 MB of bytes, no newlines anywhere.
writer = "import sys\nsys.stdout.buffer.write(b'a' * (5 * 1024 * 1024))\n"
stdout, rc, stderr, capped = session._search_capture([sys.executable, "-c", writer])
assert capped is True
assert len(stdout) == _SEARCH_RAW_BYTE_CAP
assert b"\n" not in stdout
def test_timeout_raises_even_when_child_writes_nothing(self):
"""Watchdog enforces tool_timeout regardless of whether the
child has written anything to stdout — ``proc.stdout.read`` is a
blocking pipe read that wouldn't otherwise honour the timeout.
Regression test for bug-1.
"""
import sys
session = _make_session(tool_timeout=1)
# Sleep silently — never writes to stdout — so the read blocks.
sleeper = "import time; time.sleep(30)\n"
with pytest.raises(subprocess.TimeoutExpired):
session._search_capture([sys.executable, "-c", sleeper])
def test_clean_exit_returns_full_output_uncapped(self):
"""A child that writes a small amount and exits cleanly returns
``capped=False`` and the full output verbatim."""
import sys
session = _make_session()
writer = "import sys; sys.stdout.write('a.py:1:hello\\n')\n"
stdout, rc, stderr, capped = session._search_capture([sys.executable, "-c", writer])
assert capped is False
assert rc == 0
assert stdout == b"a.py:1:hello\n"
def test_stderr_drained_without_deadlock(self):
"""If a child writes stderr in parallel with stdout, the drain
thread must keep the pipe flowing so the child doesn't block on
a full stderr buffer while we're reading stdout."""
import sys
session = _make_session()
# Write more to stderr than the OS pipe buffer (~64KB) while
# also writing stdout. Without the drain thread, the child
# blocks on stderr.write and we deadlock waiting for stdout EOF.
writer = (
"import sys\n"
"sys.stderr.buffer.write(b'e' * (200 * 1024))\n"
"sys.stdout.buffer.write(b'a.py:1:done\\n')\n"
)
stdout, rc, stderr, capped = session._search_capture([sys.executable, "-c", writer])
assert rc == 0
assert stdout == b"a.py:1:done\n"
# stderr was drained; the captured prefix is bounded by the cap.
from turnstone.core.session import _SEARCH_STDERR_CAP
assert len(stderr) <= _SEARCH_STDERR_CAP
+21 -139
View File
@@ -4,6 +4,8 @@ from __future__ import annotations
from unittest.mock import MagicMock
import pytest
from turnstone.core.attachments import Attachment
from turnstone.core.memory import (
get_attachment,
@@ -273,149 +275,29 @@ class TestProviderIntegration:
assert "DO THE THING" in parts[1]["text"]
class TestQueuedWithAttachments:
"""Queued user turns must carry their attachments through to dequeue."""
class TestQueuedAttachmentsRejected:
"""Queued user messages can't carry attachments — see
:class:`AttachmentsNotQueueableError` for the role-ordering reason
(an attachment-bearing queued item would have to be appended as a
separate user turn, injecting ``user`` between
``assistant(tool_calls)`` and ``tool``)."""
def test_queue_message_rejects_attachments(self, tmp_db, mock_openai_client):
from turnstone.core.session import AttachmentsNotQueueableError
def test_queue_message_stores_attachment_ids(self, tmp_db, mock_openai_client):
s = _make_session(mock_openai_client)
# Seed a pending attachment owned by the session user
save_attachment("a-q1", s._ws_id, "u1", "q.md", "text/markdown", 1, "text", b"q")
cleaned, priority, msg_id = s.queue_message("queued text", attachment_ids=["a-q1"])
assert cleaned == "queued text"
with pytest.raises(AttachmentsNotQueueableError):
s.queue_message("queued text", attachment_ids=["a-q1"])
# Queue stayed empty — nothing partially committed.
assert s._queued_messages == {}
def test_queue_message_accepts_text_only(self, tmp_db, mock_openai_client):
s = _make_session(mock_openai_client)
cleaned, priority, msg_id = s.queue_message("plain text")
assert cleaned == "plain text"
with s._queued_lock:
entry = s._queued_messages[msg_id]
# Entry shape is (cleaned, priority, attachment_ids_tuple)
assert entry[0] == "queued text"
assert entry[2] == ("a-q1",)
def test_flush_queued_injects_multipart_user_turn(self, tmp_db, mock_openai_client):
from turnstone.core.memory import reserve_attachments
s = _make_session(mock_openai_client)
save_attachment("a-f1", s._ws_id, "u1", "f.md", "text/markdown", 3, "text", b"DAT")
_c, _p, msg_id = s.queue_message("please review", attachment_ids=["a-f1"])
# Server-side would have reserved before queueing; mirror that
# so consume's token match succeeds on flush.
reserve_attachments(["a-f1"], msg_id, s._ws_id, "u1")
s._flush_queued_messages()
msgs = s.messages
assert len(msgs) == 1
msg = msgs[0]
assert msg["role"] == "user"
# Multipart shape — text + document parts
assert isinstance(msg["content"], list)
assert msg["content"][0] == {"type": "text", "text": "please review"}
doc = msg["content"][1]
assert doc["type"] == "document"
assert doc["document"]["name"] == "f.md"
assert doc["document"]["data"] == "DAT"
# And the attachment is now consumed (not pending)
assert get_attachment("a-f1")["message_id"] is not None
assert list_pending_attachments(s._ws_id, "u1") == []
def test_flush_mixed_attachment_and_text_items(self, tmp_db, mock_openai_client):
# Text-only items should combine into one turn while
# attachment-bearing items flush as separate multipart turns.
from turnstone.core.memory import reserve_attachments
s = _make_session(mock_openai_client)
save_attachment("a-mx", s._ws_id, "u1", "x.md", "text/markdown", 1, "text", b"x")
s.queue_message("first plain")
_c, _p, mid = s.queue_message("with file", attachment_ids=["a-mx"])
reserve_attachments(["a-mx"], mid, s._ws_id, "u1")
s.queue_message("another plain")
s._flush_queued_messages()
# We expect at least two user messages: one combining the plain
# items flanking the multipart turn is allowed, but the
# multipart turn must remain its own message.
user_msgs = [m for m in s.messages if m.get("role") == "user"]
multipart = [m for m in user_msgs if isinstance(m["content"], list)]
assert len(multipart) == 1
assert "with file" in multipart[0]["content"][0]["text"]
def test_flush_drops_cross_user_attachment_silently(self, tmp_db, mock_openai_client):
# A forged attachment_id belonging to another user must not
# produce an attached part — dequeue resolution re-scopes.
s = _make_session(mock_openai_client, user_id="u1")
save_attachment("a-other", s._ws_id, "u2", "other.md", "text/plain", 1, "text", b"o")
s.queue_message("hi", attachment_ids=["a-other"])
s._flush_queued_messages()
# Flushed as plain text-only turn — the forged id was scope-dropped.
msgs = s.messages
assert len(msgs) == 1
assert msgs[0]["content"] == "hi"
class TestQueueReservationLifecycle:
"""session.queue_message + dequeue_message lifecycle with reservations."""
def test_dequeue_unreserves_attachments(self, tmp_db, mock_openai_client):
from turnstone.core.memory import get_attachment, reserve_attachments
s = _make_session(mock_openai_client)
save_attachment("a-deq", s._ws_id, "u1", "x.md", "text/plain", 1, "text", b"x")
_cleaned, _priority, msg_id = s.queue_message("queued", attachment_ids=["a-deq"])
# Simulate the server reserving after queue_message
reserve_attachments(["a-deq"], msg_id, s._ws_id, "u1")
assert get_attachment("a-deq")["reserved_for_msg_id"] == msg_id
# Dequeue (user cancelled the queued send)
assert s.dequeue_message(msg_id) is True
# Reservation is released — back to pending
assert get_attachment("a-deq")["reserved_for_msg_id"] is None
assert len(list_pending_attachments(s._ws_id, "u1")) == 1
def test_flush_consumes_reserved_attachment(self, tmp_db, mock_openai_client):
from turnstone.core.memory import get_attachment, reserve_attachments
s = _make_session(mock_openai_client)
save_attachment("a-flush", s._ws_id, "u1", "y.md", "text/plain", 1, "text", b"y")
_c, _p, msg_id = s.queue_message("go", attachment_ids=["a-flush"])
reserve_attachments(["a-flush"], msg_id, s._ws_id, "u1")
# Flush — queue drain must accept the reserved-for-this-msg attachment
s._flush_queued_messages()
row = get_attachment("a-flush")
assert row["message_id"] is not None
assert row["reserved_for_msg_id"] is None # cleared on consume
# And the in-memory message is multipart with the doc attached
assert isinstance(s.messages[-1]["content"], list)
assert any(p.get("type") == "document" for p in s.messages[-1]["content"])
def test_resolve_rejects_reservation_for_other_msg(self, tmp_db, mock_openai_client):
from turnstone.core.memory import reserve_attachments
s = _make_session(mock_openai_client)
save_attachment("a-other", s._ws_id, "u1", "z.md", "text/plain", 1, "text", b"z")
reserve_attachments(["a-other"], "q-OTHER", s._ws_id, "u1")
# allow_reserved_for=None (default) → reserved rows are skipped
assert s._resolve_attachment_ids(["a-other"]) == []
# allow_reserved_for matches → accepted
out = s._resolve_attachment_ids(["a-other"], allow_reserved_for="q-OTHER")
assert [a.attachment_id for a in out] == ["a-other"]
class TestExplicitAttachmentIdsOrderPreserved:
"""session._resolve_attachment_ids must honour request order."""
def test_resolve_preserves_request_order(self, tmp_db, mock_openai_client):
s = _make_session(mock_openai_client)
# Insert in one order, request in the reverse order — resolver
# must reflect the request, not the DB's INSERT order.
save_attachment("a-1", s._ws_id, "u1", "first.md", "text/plain", 1, "text", b"1")
save_attachment("a-2", s._ws_id, "u1", "second.md", "text/plain", 1, "text", b"2")
save_attachment("a-3", s._ws_id, "u1", "third.md", "text/plain", 1, "text", b"3")
out = s._resolve_attachment_ids(["a-3", "a-1", "a-2"])
assert [a.attachment_id for a in out] == ["a-3", "a-1", "a-2"]
def test_resolve_skips_unknown_and_keeps_order(self, tmp_db, mock_openai_client):
s = _make_session(mock_openai_client)
save_attachment("a-k", s._ws_id, "u1", "k.md", "text/plain", 1, "text", b"k")
out = s._resolve_attachment_ids(["unknown", "a-k", ""])
assert [a.attachment_id for a in out] == ["a-k"]
assert s._queued_messages[msg_id] == ("plain text", priority)
class TestTokenAccounting:
+1 -1
View File
@@ -1,3 +1,3 @@
"""turnstone - Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."""
__version__ = "1.5.6"
__version__ = "1.5.7"
+28 -6
View File
@@ -22,6 +22,7 @@ from turnstone.core.adapters._ui_cleanup import cleanup_session_ui
from turnstone.core.child_source import ClusterChildSource
from turnstone.core.children_registry import ChildrenRegistry
from turnstone.core.log import get_logger
from turnstone.core.session import AttachmentsNotQueueableError
from turnstone.core.workstream import Workstream, WorkstreamKind, WorkstreamState
if TYPE_CHECKING:
@@ -310,13 +311,34 @@ class CoordinatorAdapter:
# logging) above.
def _enqueue() -> None:
# ``queue_message`` takes attachment *ids* + ``queue_msg_id``
# (which doubles as the cross-table reservation token); the
# send_id we hold IS that token. Convert Attachment objects
# to id list at enqueue time so the queued turn picks the
# files up at dequeue.
# Queued user turns can't carry attachments (see
# ``AttachmentsNotQueueableError``). The route handler's
# _enqueue catches the rejection and surfaces an
# ``attachments_busy`` status to the caller; the coord
# adapter's caller has no equivalent return channel, so
# we mirror the cleanup (release the reservation taken
# for ``_send_id``) and let session_worker.send return
# False — the only call site today
# (``_coord_create_post_install``) hits the spawn branch
# on a fresh workstream so the catch is defense-in-depth.
att_ids = [a.attachment_id for a in _attachments] if _attachments else None
session.queue_message(message, attachment_ids=att_ids, queue_msg_id=_send_id)
try:
session.queue_message(message, attachment_ids=att_ids, queue_msg_id=_send_id)
except AttachmentsNotQueueableError:
if _attachments and _send_id:
from turnstone.core.memory import (
unreserve_attachments as _unreserve,
)
try:
_unreserve(_send_id, ws_ref.id, _user_id)
except Exception:
log.debug(
"coord_adapter.attachment_unreserve_failed ws=%s",
ws_ref.id[:8],
exc_info=True,
)
raise
return session_worker.send(
ws,
+426 -56
View File
@@ -14,7 +14,6 @@ import argparse
import asyncio
import contextlib
import functools
import html
import json
import logging
import math
@@ -143,61 +142,432 @@ def _parse_int(
# Proxy helpers
# ---------------------------------------------------------------------------
# JS shim injected into proxied HTML when served through the console.
# Overrides fetch() and EventSource() so root-relative URLs
# (e.g. /v1/api/workstreams/{ws_id}/send) route through the console
# proxy at /node/{node_id}/v1/api/... instead.
# Inline JS injected into proxied server-UI pages. Two responsibilities,
# kept in one IIFE so the original window.fetch closure variable is
# available to the picker (which has to bypass the prefix shim):
#
# 1. Prefix shim \u2014 rewrites root-relative fetch() and EventSource()
# URLs to /node/{id}/... so the proxied page's API calls land
# at the console (which forwards them to the right server node).
#
# 2. Node picker \u2014 on DOMContentLoaded, prepends a node-id pill into
# the server UI's #ui-header (.appbar). Click \u2192 dropdown with
# \u2190 Console + the other healthy nodes. Replaces the earlier
# 32px back-to-console banner that used to live above the appbar.
# Lazy-fetches /api/cluster/nodes the first time the menu opens
# (cheap when the user never clicks; fresh when they do).
_JS_PROXY_SHIM = """\
(function(){
var _pfx="PREFIX_PLACEHOLDER";
var _oF=window.fetch;
window.fetch=function(u,o){
if(typeof u==="string"&&u.startsWith("/"))u=_pfx+u;
return _oF.call(this,u,o);
var _pfx = "PREFIX_PLACEHOLDER";
var _nodeId = "NODE_ID_PLACEHOLDER";
var _oF = window.fetch;
window.fetch = function(u, o){
if (typeof u === "string" && u.startsWith("/")) u = _pfx + u;
return _oF.call(this, u, o);
};
var _oE=window.EventSource;
window.EventSource=function(u,o){
if(typeof u==="string"&&u.startsWith("/"))u=_pfx+u;
return new _oE(u,o);
var _oE = window.EventSource;
window.EventSource = function(u, o){
if (typeof u === "string" && u.startsWith("/")) u = _pfx + u;
return new _oE(u, o);
};
window.EventSource.prototype=_oE.prototype;
window.EventSource.CONNECTING=_oE.CONNECTING;
window.EventSource.OPEN=_oE.OPEN;
window.EventSource.CLOSED=_oE.CLOSED;
window.EventSource.prototype = _oE.prototype;
window.EventSource.CONNECTING = _oE.CONNECTING;
window.EventSource.OPEN = _oE.OPEN;
window.EventSource.CLOSED = _oE.CLOSED;
function el(tag, cls, text){
var n = document.createElement(tag);
if (cls) n.className = cls;
if (text != null) n.textContent = text;
return n;
}
function buildPicker(){
var header = document.getElementById("ui-header");
if (!header) return;
// Trigger pill \u2014 prepended into #ui-header (the server UI's appbar).
var pill = document.createElement("button");
pill.type = "button";
pill.className = "console-node-pill";
pill.setAttribute("aria-haspopup", "menu");
pill.setAttribute("aria-expanded", "false");
pill.setAttribute("aria-label", "Switch node, currently " + _nodeId);
// title gives sighted users the full id when it ellipsizes
// \u2014 see the max-width + text-overflow rules in _CONSOLE_PROXY_STYLE.
pill.setAttribute("title", _nodeId);
pill.appendChild(el("span", "console-node-pill-dot"));
pill.appendChild(el("span", "console-node-pill-id", _nodeId));
pill.appendChild(el("span", "console-node-pill-caret", "\u25be"));
header.insertBefore(pill, header.firstChild);
// Menu state lives at the picker level, not on the menu DOM, so a
// close-then-reopen reuses the cached node list (no stale spinner).
var menu = null;
var loaded = false;
var loading = false;
var lastNodes = [];
var closeHandler = null;
function closeMenu(){
if (menu){ menu.remove(); menu = null; }
if (closeHandler){
document.removeEventListener("mousedown", closeHandler);
document.removeEventListener("keydown", closeHandler);
closeHandler = null;
}
pill.setAttribute("aria-expanded", "false");
}
function openMenu(){
if (menu) return;
// Reuse the workstream-tab dropdown shell for visual + behavioural
// consistency with the chevron menu next to it in the same toolbar.
menu = document.createElement("div");
menu.className = "ws-tab-dropdown console-node-menu";
menu.setAttribute("role", "menu");
menu.setAttribute("aria-label", "Switch node");
menu.addEventListener("contextmenu", function(e){ e.preventDefault(); });
document.body.appendChild(menu);
pill.setAttribute("aria-expanded", "true");
if (loaded){
renderMenu(lastNodes);
} else if (loading){
menu.appendChild(skeleton());
positionMenu();
} else {
menu.appendChild(skeleton());
positionMenu();
loadNodes();
}
// Keyboard handler kept in lockstep with the workstream-tab dropdown
// in turnstone/ui/static/app.js (search for _tabDropdownCloseHandler).
// If you change the keys here, change them there. The only intentional
// divergence is the :not([aria-disabled='true']) filter the picker
// skips disabled rows (current + unreachable) during arrow-key cycling.
closeHandler = function(e){
if (e.type === "keydown"){
if (e.key === "Escape"){
e.preventDefault();
closeMenu();
pill.focus();
} else if (e.key === "Tab"){
// Per ARIA APG menu pattern: Tab closes the menu AND moves
// focus to the next focusable element. Don't preventDefault —
// let the browser do its native Tab traversal.
closeMenu();
} else if (e.key === "ArrowDown" || e.key === "ArrowUp"
|| e.key === "Home" || e.key === "End"){
e.preventDefault();
if (!menu) return;
var btns = Array.from(
menu.querySelectorAll(".ws-tab-dropdown-item:not([aria-disabled='true'])")
);
if (!btns.length) return;
var idx = btns.indexOf(document.activeElement);
if (e.key === "ArrowDown") btns[(idx + 1) % btns.length].focus();
// idx <= 0 covers both "first item" (wrap to last) and "no
// current focus" (idx === -1, which would otherwise yield N-2
// via the modulo). Same shape worth backporting to app.js.
else if (e.key === "ArrowUp") btns[idx <= 0 ? btns.length - 1 : idx - 1].focus();
else if (e.key === "Home") btns[0].focus();
else if (e.key === "End") btns[btns.length - 1].focus();
}
} else if (e.type === "mousedown"
&& menu && !menu.contains(e.target)
&& e.target !== pill && !pill.contains(e.target)){
closeMenu();
}
};
// Defer listener wiring + initial focus so the click that opened
// the menu doesn't immediately trigger the mousedown-close path.
var activeMenu = menu;
var activeHandler = closeHandler;
setTimeout(function(){
if (menu !== activeMenu || !activeHandler) return;
document.addEventListener("mousedown", activeHandler);
document.addEventListener("keydown", activeHandler);
var first = activeMenu.querySelector(
".ws-tab-dropdown-item:not([aria-disabled='true'])"
);
if (first) first.focus();
}, 0);
}
function positionMenu(){
if (!menu) return;
var pr = pill.getBoundingClientRect();
var mr = menu.getBoundingClientRect();
var mx = pr.left;
var my = pr.bottom + 4;
if (my + mr.height > window.innerHeight) my = pr.top - mr.height - 4;
if (mx + mr.width > window.innerWidth) mx = window.innerWidth - mr.width - 4;
if (mx < 4) mx = 4;
menu.style.left = mx + "px";
menu.style.top = my + "px";
}
function skeleton(){
var box = el("div", "console-node-skeleton");
box.setAttribute("role", "status");
box.setAttribute("aria-label", "Loading nodes");
// Three rows: roughly the typical small-cluster size. CSS fades
// opacity per :nth-child (1.0 / 0.7 / 0.5) adding a fourth would
// need a fourth opacity stop to avoid visual repetition.
for (var i = 0; i < 3; i++) box.appendChild(el("div", "console-node-skeleton-row"));
return box;
}
function loadNodes(){
loading = true;
// Saved original fetch \u2014 the prefix shim above would otherwise
// rewrite this to /node/{id}/v1/api/cluster/nodes, which the node
// doesn't serve (it's a console-only endpoint mounted at /v1).
// limit=1000 requests the collector's hard maximum in one round-trip;
// beyond 1000 nodes the picker UI is no longer the right shape (it'd
// need a search box) so we don't try to paginate.
_oF.call(window, "/v1/api/cluster/nodes?limit=1000", { credentials: "same-origin" })
.then(function(r){ if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
.then(function(data){
loaded = true; loading = false;
lastNodes = Array.isArray(data && data.nodes) ? data.nodes : [];
if (menu) renderMenu(lastNodes);
})
.catch(function(){
loading = false;
if (menu) renderError();
});
}
function renderError(){
var status = el("div", "console-node-menu-status", "Failed to load nodes");
var retry = document.createElement("button");
retry.type = "button";
retry.className = "ws-tab-dropdown-item console-node-menu-item";
retry.setAttribute("role", "menuitem");
retry.setAttribute("tabindex", "-1");
retry.appendChild(el("span", "ws-tab-dropdown-label", "Retry"));
retry.addEventListener("click", function(e){
e.stopPropagation();
loaded = false;
if (menu){ menu.replaceChildren(skeleton()); positionMenu(); }
loadNodes();
});
menu.replaceChildren(status, retry);
positionMenu();
setTimeout(function(){ retry.focus(); }, 0);
}
function buildBackItem(){
var back = document.createElement("a");
back.href = "/";
back.className = "ws-tab-dropdown-item console-node-menu-item console-node-menu-back";
back.setAttribute("role", "menuitem");
back.setAttribute("tabindex", "-1");
back.setAttribute("aria-label", "Back to console");
back.appendChild(el("span", "console-node-menu-arrow", "\u2190"));
back.appendChild(el("span", "ws-tab-dropdown-label", "Console"));
return back;
}
function buildNodeItem(n){
var nid = n.node_id || "";
if (!nid) return null;
var isCurrent = nid === _nodeId;
var reachable = n.reachable !== false;
var hStatus = (n.health && n.health.status) || "";
var status = !reachable ? "unreachable"
: (hStatus && hStatus !== "ok" ? "degraded" : "healthy");
var dotMod = status === "healthy" ? "" : status;
var wsTotal = n.ws_total != null ? n.ws_total : 0;
// Current + unreachable rows are non-interactive: rendered as <div>
// with aria-disabled so the keyboard-nav filter skips them and
// mouse clicks land on dead text. A clickable <a> for an
// unreachable node would route the user to a 502 page.
var nonInteractive = isCurrent || !reachable;
var item;
if (nonInteractive){
item = document.createElement("div");
} else {
item = document.createElement("a");
item.href = "/node/" + encodeURIComponent(nid) + "/";
}
item.className = "ws-tab-dropdown-item console-node-menu-item"
+ (isCurrent ? " is-current" : "")
+ (!reachable && !isCurrent ? " is-unreachable" : "");
item.setAttribute("role", "menuitem");
item.setAttribute("tabindex", "-1");
if (isCurrent) item.setAttribute("aria-current", "true");
if (nonInteractive) item.setAttribute("aria-disabled", "true");
item.setAttribute(
"aria-label",
nid + ", " + wsTotal + " workstream" + (wsTotal === 1 ? "" : "s")
+ ", " + status + (isCurrent ? ", current node" : "")
);
var dot = el("span",
"console-node-menu-item-dot"
+ (dotMod ? " console-node-menu-item-dot--" + dotMod : ""));
dot.setAttribute("aria-hidden", "true");
item.appendChild(dot);
item.appendChild(el("span", "ws-tab-dropdown-label console-node-menu-item-id", nid));
// Meta carries ws-count + status text \u2014 the text suffix doubles as
// a colorblind-safe encoding of the dot color. aria-hidden because
// the menuitem aria-label already says it.
var metaText = wsTotal + " ws" + (status !== "healthy" ? " \u00b7 " + status : "");
var meta = el("span", "ws-tab-dropdown-key", metaText);
meta.setAttribute("aria-hidden", "true");
item.appendChild(meta);
if (isCurrent){
var check = el("span", "console-node-menu-item-check", "\u2713");
check.setAttribute("aria-hidden", "true");
item.appendChild(check);
}
return item;
}
function renderMenu(nodes){
var children = [buildBackItem()];
var nodeItems = [];
nodes.forEach(function(n){
var it = buildNodeItem(n);
if (it) nodeItems.push(it);
});
if (nodeItems.length){
var sep = el("div", "ws-tab-dropdown-sep");
sep.setAttribute("role", "separator");
children.push(sep);
children = children.concat(nodeItems);
}
menu.replaceChildren(...children);
positionMenu();
// First-open path: openMenu()'s deferred focus hook ran before the
// async fetch resolved, so it found only the skeleton and left
// focus on the pill. If focus is still on the pill (i.e. the user
// didn't navigate away while the skeleton was up), grab it now.
if (document.activeElement === pill){
var first = menu.querySelector(
".ws-tab-dropdown-item:not([aria-disabled='true'])"
);
if (first) first.focus();
}
}
pill.addEventListener("click", function(e){
e.stopPropagation();
if (menu) closeMenu(); else openMenu();
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", buildPicker);
} else {
buildPicker();
}
})();
"""
_CONSOLE_BANNER_TEMPLATE = (
'<div class="console-banner">'
'<a href="/" class="ts-header-back-link" aria-label="Return to console">'
'<span class="ts-header-back-link-arrow" aria-hidden="true">&larr;</span>'
"<span>Console</span>"
"</a>"
'<span class="console-banner-sep" aria-hidden="true">\u2502</span>'
'<a href="NODE_LINK_PLACEHOLDER" class="console-banner-node"'
' aria-label="Node: NODE_ID_PLACEHOLDER">'
"NODE_ID_PLACEHOLDER</a>"
"</div>"
)
# Injected <style>: offsets fixed-position overlays + styles the console
# return-banner against the server UI's existing design tokens. The
# banner uses the shared .ts-header-back-link class (defined in
# shared_static/chat.css, loaded by the interactive UI) so both the
# coordinator-page back-link and this banner present identical back-to-
# console affordances. Only the banner-local layout bits (sep + node
# link typography) stay scoped here.
# Inline <style> injected into proxied server-UI pages. The dropdown
# panel itself reuses .ws-tab-dropdown* (defined in ui/static/style.css,
# which the proxied page already loads) for animation, shadow, theme
# override, and item layout. This sheet adds:
# - the trigger pill (no analogue exists in the server UI),
# - the inline health-dot in menu items (mirrors --green / --accent /
# --red from the cluster-overview node table \u2014 see
# console/static/style.css:535-549),
# - the "you are here" tint + cursor:default for the current node row,
# - a 3-row pulse skeleton for the loading state.
_CONSOLE_PROXY_STYLE = (
"<style>"
".dashboard-overlay{top:32px!important}"
".console-banner{background:var(--bg-surface);"
"border-bottom:1px solid var(--border-strong);"
"padding:4px 20px;font-family:var(--font-mono);font-size:11px;"
"display:flex;align-items:center;gap:8px;position:relative;z-index:200}"
".console-banner-sep{color:var(--fg-dim);opacity:0.6}"
".console-banner-node{color:var(--fg-dim);text-decoration:none;"
"font-size:10px;letter-spacing:0.02em}"
".console-banner-node:hover{color:var(--accent)}"
# --- Trigger pill \u2014 sits at the start of #ui-header (.appbar).
# Height 24px passes WCAG 2.5.8 (24px min target) and harmonises
# with .btn (28px) and .appbar-back (~20px) without looking stunted.
# max-width caps the pill against pathologically long node ids
# (validated up to 256 chars upstream); the id span ellipsizes
# inside. min-width:0 lets it shrink under appbar pressure.
".console-node-pill{display:inline-flex;align-items:center;gap:6px;"
"height:24px;padding:0 10px;max-width:240px;min-width:0;"
"font-family:var(--font-mono);font-size:12px;color:var(--fg-dim);"
"background:transparent;border:1px solid var(--border-strong);"
"border-radius:var(--radius-sm);cursor:pointer;line-height:1;"
"transition:background .12s,color .12s}"
".console-node-pill:hover{background:var(--bg-highlight);color:var(--fg)}"
'.console-node-pill[aria-expanded="true"]{background:var(--bg-highlight);'
"color:var(--fg);border-color:var(--accent-dim)}"
".console-node-pill:focus-visible{outline:2px solid var(--accent);"
"outline-offset:2px}"
".console-node-pill-dot{width:6px;height:6px;border-radius:50%;"
"background:var(--green);box-shadow:0 0 4px var(--green-glow);"
"flex-shrink:0}"
".console-node-pill-id{font-weight:500;overflow:hidden;"
"text-overflow:ellipsis;white-space:nowrap;min-width:0}"
".console-node-pill-caret{font-size:10px;color:var(--fg-dim);opacity:.7;"
"display:inline-block;transition:transform .12s}"
'.console-node-pill[aria-expanded="true"] .console-node-pill-caret'
"{transform:rotate(180deg)}"
# --- Menu shell uses .ws-tab-dropdown directly; no CSS needed here.
# Constrain the picker's width so node ids + meta have room.
".console-node-menu{min-width:240px;max-width:360px}"
# --- Menu items reuse .ws-tab-dropdown-item \u2014 we only override
# font (mono, for hostname-like ids) and add the dot column.
".console-node-menu-item{font-family:var(--font-mono);font-size:12px;"
"padding:6px 12px;gap:8px;color:var(--fg-dim);text-decoration:none}"
# Current row: keep the accent-tint visible. The shared
# .ws-tab-dropdown-item[aria-disabled="true"] rule applies opacity:.55
# which would otherwise wash out the "you are here" tint \u2014 restore
# full opacity here. Same restore for the unreachable row's red dot
# so its color signal stays legible against the dim row background.
".console-node-menu-item.is-current{background:var(--accent-dim);"
"color:var(--fg);cursor:default;opacity:1}"
".console-node-menu-item.is-current:hover{background:var(--accent-dim);"
"color:var(--fg)}"
# Unreachable row: dim the text but leave the dot at full saturation
# so the red signal reads against the dim row. cursor:not-allowed
# comes from the shared aria-disabled rule.
".console-node-menu-item.is-unreachable{color:var(--fg-dim)}"
".console-node-menu-item.is-unreachable .console-node-menu-item-dot{opacity:1}"
".console-node-menu-back{color:var(--accent)}"
".console-node-menu-back:hover{color:var(--accent)}"
".console-node-menu-arrow{font-family:var(--font-mono);font-size:13px}"
# Health dots in menu items \u2014 match cluster-overview canonical colors:
# reachable + ok \u2192 --green (style.css:539)
# reachable + !ok \u2192 --accent (style.css:548 \u2014 was --yellow)
# unreachable \u2192 --red (style.css:544)
".console-node-menu-item-dot{width:6px;height:6px;border-radius:50%;"
"flex-shrink:0;background:var(--green);box-shadow:0 0 4px var(--green-glow)}"
".console-node-menu-item-dot--unreachable{background:var(--red);"
"box-shadow:0 0 4px var(--red-glow)}"
".console-node-menu-item-dot--degraded{background:var(--accent);"
"box-shadow:0 0 4px var(--accent-glow-strong)}"
".console-node-menu-item-id{flex:1}"
".console-node-menu-item-check{color:var(--accent);font-size:11px}"
# --- Loading state: 3-row pulsing skeleton. Reuses --border-strong
# for the row tint and a dedicated keyframe so we can guard it under
# prefers-reduced-motion in step.
".console-node-skeleton{padding:6px 0}"
".console-node-skeleton-row{height:14px;margin:6px 12px;"
"background:var(--border-strong);border-radius:var(--radius-sm);"
"animation:console-node-skel-pulse 1.4s ease-in-out infinite}"
".console-node-skeleton-row:nth-child(2){opacity:.7;animation-delay:.15s}"
".console-node-skeleton-row:nth-child(3){opacity:.5;animation-delay:.3s}"
"@keyframes console-node-skel-pulse{"
"0%,100%{opacity:.4}50%{opacity:.8}}"
"@media (prefers-reduced-motion:reduce){"
".console-node-skeleton-row{animation:none}}"
# --- Status text fallback (only used by the error path now).
".console-node-menu-status{padding:8px 12px;font-family:var(--font-mono);"
"font-size:11px;color:var(--fg-dim);text-align:center}"
"</style>"
)
@@ -2134,16 +2504,16 @@ async def proxy_index(request: Request) -> Response:
page = page.replace('src="/static/', f'src="{prefix}/static/')
page = page.replace('href="/shared/', f'href="{prefix}/shared/')
page = page.replace('src="/shared/', f'src="{prefix}/shared/')
# Inject console-return banner + proxy shim after <body>
banner = _CONSOLE_BANNER_TEMPLATE.replace(
"NODE_ID_PLACEHOLDER", html.escape(node_id)
).replace("NODE_LINK_PLACEHOLDER", html.escape(prefix + "/"))
shim = (
"<script>"
+ _JS_PROXY_SHIM.replace('"PREFIX_PLACEHOLDER"', json.dumps(prefix))
+ "</script>"
# Inject the proxy shim (prefix rewriting + node-picker) after <body>.
# The picker self-attaches to #ui-header on DOMContentLoaded; the
# banner that used to live above the appbar is gone. node_id is
# validated against _VALID_NODE_ID upstream, so json.dumps is the
# only escaping the JS literal needs.
shim_js = _JS_PROXY_SHIM.replace('"PREFIX_PLACEHOLDER"', json.dumps(prefix)).replace(
'"NODE_ID_PLACEHOLDER"', json.dumps(node_id)
)
page = page.replace("<body>", "<body>" + banner + _CONSOLE_PROXY_STYLE + shim, 1)
shim = "<script>" + shim_js + "</script>"
page = page.replace("<body>", "<body>" + _CONSOLE_PROXY_STYLE + shim, 1)
html_resp = HTMLResponse(page)
html_resp.headers["Cache-Control"] = "no-cache"
return html_resp
@@ -1591,6 +1591,17 @@
appendText("error", "Message queue full. Please wait.", {
label: "error",
});
} else if (data && data.status === "attachments_busy") {
// Attachments can't ride a queued user turn — server held
// the reservations long enough to bounce the request and
// released them. Chips stay in the composer; user retries
// once the assistant finishes.
if (queuedEl) queue.remove(queuedEl);
appendText(
"error",
"Attachments can't be sent while the assistant is working. Send a text-only message now, or wait and resend with attachments.",
{ label: "error" },
);
} else {
attachments.consume(
data && data.attached_ids,
+9
View File
@@ -75,6 +75,15 @@ _EXPLICIT_SCRUB: frozenset[str] = frozenset(
"GOOGLE_APPLICATION_CREDENTIALS",
"DATABASE_URL", # conventional name (Heroku, Railway, etc.) — kept for defence-in-depth
"TURNSTONE_DB_URL",
# Tool-config env vars whose target files can directly load
# executable directives (preprocessor commands, pagers, etc.).
# Defence-in-depth alongside the on-CLI ``--no-config`` we pass
# to ripgrep — if a future caller forgets that flag, an attacker
# who can set one of these can plant a config that runs commands.
"RIPGREP_CONFIG_PATH",
"GIT_CONFIG",
"GIT_CONFIG_GLOBAL",
"GIT_CONFIG_SYSTEM",
}
)
+1 -1
View File
@@ -182,7 +182,7 @@ OPENAI_CAPABILITIES: dict[str, ModelCapabilities] = {
}
# Default for unknown models (local servers: vLLM, llama.cpp, etc.)
OPENAI_DEFAULT = ModelCapabilities(supports_tool_advisories=False)
OPENAI_DEFAULT = ModelCapabilities()
def lookup_openai_capabilities(model: str) -> ModelCapabilities:
-1
View File
@@ -86,7 +86,6 @@ class ModelCapabilities:
supports_web_search: bool = False
supports_tool_search: bool = False
supports_vision: bool = False
supports_tool_advisories: bool = True
thinking_display: str = "" # "summarized" for models that omit thinking by default
+483 -193
View File
@@ -15,6 +15,7 @@ import contextlib
import copy
import dataclasses
import difflib
import functools
import hashlib
import json
import mimetypes
@@ -51,7 +52,6 @@ from turnstone.core.memory import (
delete_messages_after,
delete_structured_memory,
delete_workstream,
get_attachments,
get_skill_by_name,
get_structured_memory_by_name,
get_workstream_display_name,
@@ -74,7 +74,6 @@ from turnstone.core.memory import (
search_structured_memories,
search_visible_structured_memories,
set_workstream_alias,
unreserve_attachments,
update_workstream_title,
)
from turnstone.core.memory_relevance import (
@@ -146,6 +145,24 @@ class GenerationCancelled(BaseException):
"""
class AttachmentsNotQueueableError(Exception):
"""Raised by ``ChatSession.queue_message`` when called with non-empty
``attachment_ids``.
Queued messages are injected at the next tool-result advisory seam
where they ride inside the tool envelope as text-only
``UserInterjection`` advisories. Attachments can't ride that path
(advisories don't carry image / file blocks), so an attachment-
bearing queued item would have to be appended as a separate
``user`` turn which would inject ``user`` between
``assistant(tool_calls)`` and ``tool``, a role sequence strict
providers (Mistral, Anthropic) reject.
Callers surface this to the user as "wait for the current turn
before attaching".
"""
class _CancelRef(list[Any]):
"""List proxy used for ``ChatSession._cancel_ref``.
@@ -195,6 +212,283 @@ def _encode_image_data_uri(raw: bytes, mime: str) -> str:
# Upper bound on total skill content injected into system messages
_MAX_SKILL_CONTENT: int = 32768
# Cap on the *content portion* (text after ``path:lineno:``) of an
# emitted search result line. Defends the context budget against
# pathological lines (minified blobs, base64 data, etc.).
_MAX_SEARCH_LINE_LENGTH: int = 1024
# Margin over the per-line cap before re-truncating, so backend-supplied
# preview markers (e.g. ripgrep's ``[... omitted end of long line]``) pass
# through cleanly without redundant " ...[truncated]" stacking.
_SEARCH_LINE_MARGIN: int = 128
_SEARCH_TRUNCATION_SUFFIX: str = f"...[truncated, line length > {_MAX_SEARCH_LINE_LENGTH}]"
_SEARCH_ALL_TRUNCATED_MSG: str = (
"(all matches returned were malformed -- re-check your search query, "
"if the issue persists there may be a problem with the search backend or the filesystem)"
)
# Total search-output budget (chars). Chosen well under ``tool_truncation``
# (typically 256 KB+) so the head+tail ``_truncate_output`` strategy never
# kicks in for search results — that strategy silently drops middle files
# alphabetically, which is exactly the wrong shape for a grep result.
_SEARCH_OUTPUT_BUDGET: int = 32_768
# Hard cap on raw bytes read from the search subprocess. Defends against
# pathological single-line files (multi-GB JSONL training records, etc.)
# that would otherwise OOM the parent process via ``subprocess.run``.
_SEARCH_RAW_BYTE_CAP: int = 4 * 1024 * 1024
# Files larger than this are skipped entirely (ripgrep only — grep has no
# native equivalent and falls back to the byte cap above).
_SEARCH_MAX_FILESIZE: str = "10M"
# Per-file sample-count ladder for Tier 2 degradation. Each step is tried in
# order; the first K whose total emission fits the budget wins. The full
# 5/3/1 curve documents the degradation: prefer 5 samples per file, fall to
# 3, then a single representative sample before giving up to Tier 3.
_SEARCH_TIER2_SAMPLE_LADDER: tuple[int, ...] = (5, 3, 1)
# Bytes reserved at the end of the Tier 3 body for the
# "(plus N more files with M matches between them)" tail line, so we don't
# blow the budget when the count list itself is enormous.
_SEARCH_TIER3_TAIL_RESERVE: int = 80
# Stderr-drain knobs for ``_search_capture``: bound the captured stderr so
# a hostile child can't grow the buffer indefinitely, and drain in
# moderate-sized chunks so the OS pipe buffer doesn't deadlock the child.
_SEARCH_STDERR_CAP: int = 64 * 1024
_SEARCH_DRAIN_CHUNK: int = 8192
# How long we wait for the stderr drain thread to finish after the child
# exits. The thread reads from a closed pipe at that point; a small
# timeout keeps shutdown bounded if the OS hasn't propagated EOF yet.
_SEARCH_DRAIN_JOIN_TIMEOUT: float = 2.0
# Excluded directory patterns — hit by both backends. ripgrep also respects
# ``.gitignore`` and skips hidden directories by default, so most of these
# are belt-and-suspenders for the rg path; they're load-bearing for grep.
_SEARCH_EXCLUDE_DIRS: tuple[str, ...] = (
".git",
"node_modules",
"target",
"__pycache__",
".mypy_cache",
".ruff_cache",
".pytest_cache",
"dist",
"build",
"*.egg-info",
".tox",
".venv",
"venv",
"vendor",
)
@functools.cache
def _detect_search_backend() -> str:
"""Return ``'rg'`` if ripgrep is on PATH, else ``'grep'``. Cached."""
return "rg" if shutil.which("rg") else "grep"
def _build_search_args(pattern: str, path: str, backend: str) -> list[str]:
"""Build subprocess args for the chosen search backend.
The ripgrep flag set is the load-bearing one: ``--max-columns`` +
``--max-columns-preview`` bound per-line bytes natively (no Python-side
re-search needed), ``--max-filesize`` skips multi-MB JSONL/training
files entirely, and ``--max-count`` matches grep's ``-m`` per-file cap.
"""
if backend == "rg":
args = [
"rg",
"-n", # line numbers
"-H", # always show filename
"--no-heading", # path:line:content format like grep
"--color=never",
"--no-config", # ignore ~/.ripgreprc for reproducibility
"--no-messages", # suppress filesystem error noise
"--max-count",
"100",
"--max-columns",
str(_MAX_SEARCH_LINE_LENGTH),
"--max-columns-preview", # show first N cols + omitted-marker
"--max-filesize",
_SEARCH_MAX_FILESIZE,
]
for d in _SEARCH_EXCLUDE_DIRS:
args.extend(["-g", f"!{d}"])
# ``-e`` protects the pattern from being parsed as a flag; ``--``
# protects the path the same way. Without ``--`` an attacker who
# can prompt-inject the agent could pass ``path="--pre=COMMAND"``
# and ripgrep would execute COMMAND as a per-file preprocessor.
args.extend(["-e", pattern, "--", path])
return args
# grep fallback
args = ["grep", "-rn", "-I", "-E", "-m", "100", "--color=never"]
for d in _SEARCH_EXCLUDE_DIRS:
args.append(f"--exclude-dir={d}")
args.extend(["--", pattern, path])
return args
def _parse_search_records(stdout: bytes) -> list[tuple[str, str, str]]:
"""Parse ``path:lineno:content`` records from search backend stdout.
Drops malformed lines (need 2 colons, numeric line-number, non-empty
path). Decodes bytes leniently for display. Lines that exceed the
per-line cap *plus* a small margin for backend-supplied truncation
markers are re-truncated with ``_SEARCH_TRUNCATION_SUFFIX``; this is
the load-bearing defense for the grep fallback (rg already enforces
``--max-columns`` upstream).
"""
cap = _MAX_SEARCH_LINE_LENGTH
margin = _SEARCH_LINE_MARGIN
# Decode the whole buffer once rather than per-line — a cap-hit
# invocation can yield ~50K lines, and the per-line ``decode()`` was
# showing up in profiles.
text = stdout.decode("utf-8", errors="replace")
records: list[tuple[str, str, str]] = []
for line in text.splitlines():
path, sep1, rest = line.partition(":")
if not sep1 or not path:
continue
lineno, sep2, content = rest.partition(":")
if not sep2 or not lineno.isdigit():
continue
if len(content) > cap + margin:
content = content[:cap] + _SEARCH_TRUNCATION_SUFFIX
records.append((path, lineno, content))
return records
def _format_search_results(
records: list[tuple[str, str, str]],
capped: bool,
) -> str:
"""Format match records with tiered degradation when output > budget.
Tier 1: full ``path:line:content`` lines, stream-emitted with a running
cost check that short-circuits as soon as the budget would be exceeded.
Tier 2: K samples per file plus an ``and N more in <path>`` note. K is
seeded from a per-file size estimate, then stepped down through the
(5, 3, 1) ladder from the highest rung the estimate until the
emission fits. This guarantees every file is at least mentioned, which
prevents the alphabetic-bias dropout that head+tail truncation produced.
Tier 3 (fallback): per-file counts only, sorted by descending count.
"""
by_file: dict[str, list[tuple[str, str]]] = {}
for path, lineno, content in records:
by_file.setdefault(path, []).append((lineno, content))
total = len(records)
files = len(by_file)
if not total:
# Caller distinguishes "no matches" from "all malformed" via rc.
return _SEARCH_ALL_TRUNCATED_MSG
summary = f"\n\n({total} matches across {files} files)"
if capped:
summary += " (raw output exceeded byte cap; results may be incomplete)"
chunks: list[str] = []
used = 0
overflow = False
for path, matches in by_file.items():
for lineno, content in matches:
line = f"{path}:{lineno}:{content}"
cost = len(line) + 1
if used + cost + len(summary) > _SEARCH_OUTPUT_BUDGET:
overflow = True
break
chunks.append(line)
used += cost
if overflow:
break
if not overflow:
return "\n".join(chunks) + summary
# Tier 2 header is added on return; budget for it up front so the
# final emission stays strictly within ``_SEARCH_OUTPUT_BUDGET`` and
# ``_truncate_output``'s head+tail strategy never kicks in (that
# strategy silently drops middle files alphabetically — exactly the
# shape we're trying to avoid for search results).
def _tier2_header(k_value: int) -> str:
h = (
f"({total} matches across {files} files — "
f"showing first {k_value}/file. Narrow the query or read_file "
f"a specific path for full content.)"
)
if capped:
h += " (raw output capped; counts may underreport.)"
return h
# Sample the first ~32 records for an emitted-line-length estimate,
# then seed K from ``budget / (files * avg)`` so we usually skip
# ladder rungs that won't fit in one pass. Floor avg at 80 so a
# corpus of unusually short lines doesn't push K artificially high
# (the estimate would underweight the per-line newline + trailing
# "...and N more in <path>" notes). The ladder iteration below is
# the safety net — the estimate is approximate.
sample = records[:32]
avg = max(
80,
sum(len(p) + len(ln) + len(c) + 3 for p, ln, c in sample) // max(1, len(sample)),
)
estimated_k = max(1, _SEARCH_OUTPUT_BUDGET // max(1, files * (avg + 1)))
# Iterate the ladder starting from the highest rung that's ≤ our
# estimate. If the chosen K's actual emission doesn't fit (the
# estimate ignored the header and over-counts compression from
# shared paths), step down to the next rung instead of jumping
# straight to Tier 3. The ``or [...]`` is defence against future
# changes to the ladder constant; with the current (5, 3, 1) it
# never fires because ``estimated_k`` is floored at 1 above.
candidates = [k for k in _SEARCH_TIER2_SAMPLE_LADDER if k <= estimated_k] or [
_SEARCH_TIER2_SAMPLE_LADDER[-1]
]
for k in candidates:
header = _tier2_header(k)
# Budget for header + the "\n\n" separator on return.
body_budget = _SEARCH_OUTPUT_BUDGET - len(header) - 2
chunks2: list[str] = []
used2 = 0
fit = True
for path, matches in by_file.items():
head = matches[:k]
for lineno, content in head:
line = f"{path}:{lineno}:{content}"
chunks2.append(line)
used2 += len(line) + 1
if len(matches) > k:
note = f" ...and {len(matches) - k} more in {path}"
chunks2.append(note)
used2 += len(note) + 1
if used2 > body_budget:
fit = False
break
if fit:
return header + "\n\n" + "\n".join(chunks2)
counts = sorted(by_file.items(), key=lambda kv: (-len(kv[1]), kv[0]))
tier3_header = (
f"({total} matches across {files} files — too many to show inline. "
f"Counts only; narrow the query or read_file a specific path.)"
)
if capped:
tier3_header += " (raw output capped; counts may underreport.)"
# Budget for header + the "\n\n" separator + the trailing
# "(plus N more files)" line so the final emission stays within
# ``_SEARCH_OUTPUT_BUDGET`` even when the count list is enormous.
tier3_body_budget = _SEARCH_OUTPUT_BUDGET - len(tier3_header) - 2 - _SEARCH_TIER3_TAIL_RESERVE
body_lines: list[str] = []
body_used = 0
shown = 0
for p, m in counts:
line = f"{p}: {len(m)} matches"
if body_used + len(line) + 1 > tier3_body_budget:
break
body_lines.append(line)
body_used += len(line) + 1
shown += 1
if shown < files:
omitted_matches = sum(len(m) for _p, m in counts[shown:])
body_lines.append(
f"(plus {files - shown} more files with {omitted_matches} matches between them)"
)
body = "\n".join(body_lines)
return tier3_header + "\n\n" + body
# Memory scopes accepted by the ``memory`` tool's preparer + executor.
# Single source of truth — every action validator imports this rather
# than literal-listing the four values, so adding a fifth scope is a
@@ -475,15 +769,10 @@ class ChatSession:
self._pending_user_advisories: list[tuple[str, str]] = [] # (type, text)
# User message queue: messages sent while model is executing.
# OrderedDict preserves FIFO order and supports O(1) removal by ID.
#
# Entry shape: ``(cleaned_text, priority, attachment_ids)``.
# Attachment lifecycle:
# pending — uploaded, not tied to any turn
# reserved — soft-locked at queue time (reserved_for_msg_id = queue id)
# consumed — committed to a saved message (message_id = conv row id)
# queue_message transitions pending → reserved for its attachments;
# _flush_queued_messages (dequeue) transitions reserved → consumed.
self._queued_messages: collections.OrderedDict[str, tuple[str, str, tuple[str, ...]]] = (
# Queued user turns never carry attachments — see
# ``AttachmentsNotQueueableError`` for the role-ordering reason —
# so the entry tuple is just ``(cleaned, priority)``.
self._queued_messages: collections.OrderedDict[str, tuple[str, str]] = (
collections.OrderedDict()
)
self._queued_lock = threading.Lock()
@@ -2475,7 +2764,12 @@ class ChatSession:
threading.Thread(target=self._generate_title, daemon=True).start()
# Flush any queued messages that weren't injected
# (no tool calls → no advisory seam to inject at).
self._flush_queued_messages()
# If anything drained, the model hasn't seen those
# messages yet — keep the loop alive so it gets a
# turn over the extended history rather than
# orphaning them until the next user send.
if self._flush_queued_messages():
continue
self._emit_state("idle")
# Dispatch any pending watch results (chains into
# a new send() within the same worker thread).
@@ -3776,16 +4070,26 @@ class ChatSession:
Thread-safe called from the HTTP handler while the worker thread
is executing. Returns ``(cleaned_text, priority, msg_id)``.
Raises ``queue.Full`` if the queue is saturated.
Raises ``queue.Full`` if the queue is saturated. Raises
:class:`AttachmentsNotQueueableError` when ``attachment_ids`` is
non-empty: attachments cannot ride the advisory seam (which is
text-only), and appending them as a separate user turn would
violate strict-provider role-ordering rules. Callers surface
this rejection to the user interactive UIs typically wait for
the current turn to finish before allowing an attached send.
``attachment_ids`` (ordered) are resolved and consumed at dequeue
time so queued multimodal turns don't silently lose their files.
``queue_msg_id`` lets the caller supply the id (so it matches the
attachment-reservation token already taken server-side) when
omitted, an id is generated.
"""
from turnstone.core.tool_advisory import parse_priority
if attachment_ids:
raise AttachmentsNotQueueableError(
"Cannot queue a message with attachments — wait for the "
"current turn to finish before sending an attachment."
)
cleaned, priority = parse_priority(text)
# Cap individual message length to prevent context bloat
if len(cleaned) > 2000:
@@ -3795,116 +4099,39 @@ class ChatSession:
# workstream_attachments, and a 48-bit truncation narrows the
# birthday bound unnecessarily.
msg_id = queue_msg_id or uuid.uuid4().hex
att_ids = tuple(attachment_ids or ())
with self._queued_lock:
if len(self._queued_messages) >= self._QUEUE_MAX:
raise queue.Full()
self._queued_messages[msg_id] = (cleaned, priority, att_ids)
self._queued_messages[msg_id] = (cleaned, priority)
return cleaned, priority, msg_id
def dequeue_message(self, msg_id: str) -> bool:
"""Remove a queued message by ID. Returns True if removed.
Releases any attachment reservation held by the queued message
so the user can re-use or delete those files.
"""
"""Remove a queued message by ID. Returns True if removed."""
with self._queued_lock:
popped = self._queued_messages.pop(msg_id, None)
if popped is None:
return False
# popped == (cleaned, priority, attachment_ids_tuple)
if popped[2]:
unreserve_attachments(msg_id, self._ws_id, self._user_id)
return True
return popped is not None
def _resolve_attachment_ids(
self,
attachment_ids: tuple[str, ...] | list[str],
allow_reserved_for: str | None = None,
) -> list[Attachment]:
"""Fetch+scope-check attachment ids, preserving request order.
def _flush_queued_messages(self) -> bool:
"""Drain queued messages into a single combined user turn.
Silently drops ids that don't belong to this session's ws+user,
are already consumed, or are reserved for a different queued
message. When ``allow_reserved_for`` is set, attachments whose
``reserved_for_msg_id`` matches are accepted (dequeue path
passes the originating queue msg id so its own reservation
releases cleanly).
"""
ids = [str(x) for x in attachment_ids if x]
if not ids:
return []
rows = get_attachments(ids)
by_id = {str(r["attachment_id"]): r for r in rows}
resolved: list[Attachment] = []
for aid in ids:
r = by_id.get(aid)
if (
not r
or r.get("ws_id") != self._ws_id
or r.get("user_id") != self._user_id
or r.get("message_id") is not None
):
continue
reserved = r.get("reserved_for_msg_id")
if reserved and reserved != allow_reserved_for:
continue
content = r.get("content")
if not isinstance(content, bytes):
continue
resolved.append(
Attachment(
attachment_id=str(r["attachment_id"]),
filename=str(r.get("filename") or ""),
mime_type=str(r.get("mime_type") or "application/octet-stream"),
kind=str(r.get("kind") or ""),
content=content,
)
)
return resolved
Queued items are always text-only (attachments are rejected at
``queue_message`` time see :class:`AttachmentsNotQueueableError`),
so a single combined turn avoids back-to-back user messages
that some models handle poorly.
def _flush_queued_messages(self) -> None:
"""Drain queued messages.
Items without attachments are combined into a single user turn
to avoid back-to-back user messages that some models handle
poorly. Items with attachments flush as separate multipart user
turns (combining text+files across distinct queued sends would
misrepresent ordering).
Returns ``True`` when any items drained, ``False`` otherwise.
"""
from turnstone.core.tool_advisory import PRIORITY_IMPORTANT
with self._queued_lock:
# .items() so we keep the queue msg id for reservation lookup
items = list(self._queued_messages.items())
items = list(self._queued_messages.values())
self._queued_messages.clear()
if not items:
return
return False
# Collapse contiguous attachment-free items into one combined text
# to preserve the prior behaviour; flush attachment-bearing items
# inline as their own multipart turns.
text_run: list[tuple[str, str]] = []
def _flush_text_run() -> None:
if not text_run:
return
parts = [
f"[IMPORTANT] {msg}" if pri == PRIORITY_IMPORTANT else msg for msg, pri in text_run
]
combined = "\n\n".join(parts)
self._append_user_turn(combined, ())
text_run.clear()
for queue_msg_id, (cleaned, priority, att_ids) in items:
if att_ids:
_flush_text_run()
text = f"[IMPORTANT] {cleaned}" if priority == PRIORITY_IMPORTANT else cleaned
resolved = self._resolve_attachment_ids(att_ids, allow_reserved_for=queue_msg_id)
self._append_user_turn(text, resolved, send_id=queue_msg_id)
else:
text_run.append((cleaned, priority))
_flush_text_run()
parts = [f"[IMPORTANT] {msg}" if pri == PRIORITY_IMPORTANT else msg for msg, pri in items]
self._append_user_turn("\n\n".join(parts), ())
return True
def _collect_advisories(
self,
@@ -3934,20 +4161,6 @@ class ChatSession:
"""
from turnstone.core.tool_advisory import GuardAdvisory, UserInterjection
caps = self._get_capabilities()
# When the model doesn't support advisory tags, still drain queued
# messages so they aren't silently orphaned — flush them as regular
# user messages instead. Metacognitive tool advisories (tool_error
# / repeat) are dropped silently: the model wouldn't reliably parse
# them anyway, and the user-channel nudges still fire on the next
# user turn.
if not caps.supports_tool_advisories:
if is_last_in_batch:
self._pending_tool_advisories.clear()
self._flush_queued_messages()
return [], []
persistent: list[ToolAdvisory] = []
metacog_reminders: list[dict[str, str]] = []
@@ -3967,27 +4180,16 @@ class ChatSession:
metacog_reminders.extend({"type": nt, "text": text} for nt, text in drained)
# Drain queued user messages on the last result in the batch.
# Attachment-bearing items fall back to a full multipart user
# turn (advisories are text-only and can't carry image blocks).
# Items are always text-only (attachments rejected at
# queue_message time), so they ride inside the tool envelope
# as text advisories — preserves the assistant→tool role
# sequence on the wire.
if is_last_in_batch:
with self._queued_lock:
items = list(self._queued_messages.items())
items = list(self._queued_messages.values())
self._queued_messages.clear()
attachment_items: list[tuple[str, str, str, tuple[str, ...]]] = []
for queue_msg_id, (msg, priority, att_ids) in items:
if att_ids:
attachment_items.append((queue_msg_id, msg, priority, att_ids))
else:
persistent.append(UserInterjection(message=msg, priority=priority))
if attachment_items:
from turnstone.core.tool_advisory import PRIORITY_IMPORTANT
for queue_msg_id, msg, priority, att_ids in attachment_items:
text = f"[IMPORTANT] {msg}" if priority == PRIORITY_IMPORTANT else msg
resolved = self._resolve_attachment_ids(
att_ids, allow_reserved_for=queue_msg_id
)
self._append_user_turn(text, resolved, send_id=queue_msg_id)
for msg, priority in items:
persistent.append(UserInterjection(message=msg, priority=priority))
return persistent, metacog_reminders
@@ -7862,69 +8064,157 @@ class ChatSession:
self._report_tool_result(call_id, "read_file", f"image ({len(raw):,} bytes)")
return call_id, content_parts
def _search_capture(self, args: list[str]) -> tuple[bytes, int, bytes, bool]:
"""Run a search subprocess with a streaming, byte-capped stdout read.
Returns ``(stdout, returncode, stderr, capped)``. Drains stderr in a
background thread to avoid pipe deadlock when the child writes a lot
to stderr while we're still reading stdout.
The byte cap is the load-bearing defense for pathological inputs
(multi-GB JSONL, single-line minified bundles): on overflow, we
kill the child and trim to the last newline so the parser never
sees a partial trailing line.
"""
from turnstone.core.env import scrubbed_env
proc = subprocess.Popen(
args,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=scrubbed_env(),
)
# Watchdog: ``proc.stdout.read`` is a blocking pipe read with no
# timeout, so a child stuck in kernel I/O (NFS, FUSE, broken
# backend) would hang forever despite ``tool_timeout``. The timer
# arms ``proc.kill`` after the deadline; we detect "child was
# killed by us, not by the byte cap" by setting ``timed_out``
# before invoking kill.
timed_out = [False]
def _watchdog() -> None:
timed_out[0] = True
with contextlib.suppress(Exception):
proc.kill()
watchdog = threading.Timer(self.tool_timeout, _watchdog)
watchdog.daemon = True
watchdog.start()
stderr_chunks: list[bytes] = []
def _drain_stderr() -> None:
if not proc.stderr:
return
total = 0
try:
while total < _SEARCH_STDERR_CAP:
chunk = proc.stderr.read(_SEARCH_DRAIN_CHUNK)
if not chunk:
return
stderr_chunks.append(chunk)
total += len(chunk)
while proc.stderr.read(_SEARCH_DRAIN_CHUNK):
pass # discard tail so the child can finish writing
except Exception:
pass # best-effort: pipe may be torn down by proc.kill()/child exit
drain_thread = threading.Thread(target=_drain_stderr, daemon=True)
drain_thread.start()
capped = False
try:
stdout = proc.stdout.read(_SEARCH_RAW_BYTE_CAP + 1) if proc.stdout else b""
if len(stdout) > _SEARCH_RAW_BYTE_CAP:
capped = True
proc.kill()
stdout = stdout[:_SEARCH_RAW_BYTE_CAP]
last_nl = stdout.rfind(b"\n")
if last_nl >= 0:
stdout = stdout[:last_nl]
rc = proc.wait()
finally:
watchdog.cancel()
drain_thread.join(timeout=_SEARCH_DRAIN_JOIN_TIMEOUT)
for stream in (proc.stdout, proc.stderr):
# best-effort: pipe may be torn down by proc.kill()/OS
with contextlib.suppress(Exception):
if stream is not None:
stream.close()
if timed_out[0]:
raise subprocess.TimeoutExpired(args, self.tool_timeout)
return stdout, rc, b"".join(stderr_chunks), capped
def _exec_search(self, item: dict[str, Any]) -> tuple[str, str]:
"""Search file contents for a regex pattern using grep."""
"""Search file contents for a regex pattern via ripgrep (preferred) or grep."""
call_id = item["call_id"]
pattern, path = item["pattern"], item["path"]
try:
from turnstone.core.env import scrubbed_env
backend = _detect_search_backend()
args = _build_search_args(pattern, path, backend)
stdout, rc, stderr, capped = self._search_capture(args)
result = subprocess.run(
[
"grep",
"-rn",
"-I",
"-E",
"-m",
"200", # max matches per file
"--color=never", # no ANSI codes in output
# Skip common build/vendor/VCS directories
"--exclude-dir=.git",
"--exclude-dir=node_modules",
"--exclude-dir=target",
"--exclude-dir=__pycache__",
"--exclude-dir=.mypy_cache",
"--exclude-dir=.ruff_cache",
"--exclude-dir=.pytest_cache",
"--exclude-dir=dist",
"--exclude-dir=build",
"--exclude-dir=*.egg-info",
"--exclude-dir=.tox",
"--exclude-dir=.venv",
"--exclude-dir=venv",
"--exclude-dir=vendor",
"--",
pattern,
path, # -- prevents pattern as flag
],
capture_output=True,
text=True,
timeout=self.tool_timeout,
env=scrubbed_env(),
)
output = result.stdout.strip()
if result.returncode == 1:
output = "(no matches)"
elif result.returncode > 1:
output = result.stderr.strip() or f"grep error (exit {result.returncode})"
# ripgrep and grep share rc semantics: 0 = matches, 1 = no
# matches, ≥2 = error. When ``capped`` is True we killed the
# child intentionally (byte-cap), so a negative rc is from
# our SIGKILL — normalise to 0 so the partial output flows
# through. But preserve a non-negative rc: there's a narrow
# race where the child can exit naturally between our read
# and our kill, and we don't want to silently swallow rg's
# rc=2 ("matches found but some files had errors") just
# because we also tripped the byte cap.
if capped and rc < 0:
rc = 0
# Count matches and files BEFORE truncation
match_count = output.count("\n") + 1 if result.returncode == 0 and output else 0
if match_count:
files = {line.split(":", 1)[0] for line in output.splitlines() if ":" in line}
file_count = len(files)
else:
file_count = 0
if rc == 1:
self._report_tool_result(call_id, "search", "no matches")
return call_id, "(no matches)"
if rc < 0:
# Signal-killed by something other than us (OOM killer,
# external SIGTERM). Surface it instead of parsing the
# truncated stdout as if the search had completed.
msg = f"{backend} killed by signal {-rc}"
self._report_tool_result(call_id, "search", msg, is_error=True)
return call_id, msg
if rc > 1:
err_text = stderr.decode("utf-8", errors="replace").strip()
msg = err_text or f"{backend} error (exit {rc})"
self._report_tool_result(call_id, "search", msg, is_error=True)
return call_id, msg
# Append summary footer before truncation so it counts toward the limit
original_len = len(output)
if match_count:
output += f"\n\n({match_count} matches across {file_count} files)"
output = self._truncate_output(output)
records = _parse_search_records(stdout)
original_len = len(stdout)
desc = f"{match_count} matches" if match_count else "no matches"
if not records:
if capped:
# The byte cap fired before any parseable line
# completed (typical shape: a single multi-MB line
# without a newline, e.g. minified bundle / training
# JSONL record). The malformed-output message would
# blame the query; surface the real cause instead.
msg = (
"(search output exceeded the raw byte cap before "
"any parseable line completed — narrow your query "
"or restrict the path)"
)
self._report_tool_result(call_id, "search", "byte cap hit", is_error=True)
return call_id, msg
# rc 0 with no parseable records means matches were found
# but every line was malformed.
self._report_tool_result(call_id, "search", "all matches malformed", is_error=True)
return call_id, _SEARCH_ALL_TRUNCATED_MSG
output = _format_search_results(records, capped)
output = self._truncate_output(output) # belt-and-suspenders
match_count = len(records)
desc = f"{match_count} matches"
if original_len > 500:
desc += f" ({original_len} chars)"
desc += f" ({original_len} bytes raw)"
if capped:
desc += " [capped]"
self._report_tool_result(call_id, "search", desc)
return call_id, output
+27 -8
View File
@@ -2513,7 +2513,7 @@ def make_send_handler(cfg: SessionEndpointConfig) -> Handler:
import uuid
from turnstone.core import session_worker
from turnstone.core.session import GenerationCancelled
from turnstone.core.session import AttachmentsNotQueueableError, GenerationCancelled
from turnstone.core.web_helpers import read_json_or_400
async def send(request: Request) -> Response:
@@ -2676,11 +2676,15 @@ def make_send_handler(cfg: SessionEndpointConfig) -> Handler:
queue_outcome: dict[str, Any] = {}
def _enqueue() -> None:
cleaned, priority, msg_id = session.queue_message(
message,
attachment_ids=list(ordered_reserved),
queue_msg_id=send_id or None,
)
try:
cleaned, priority, msg_id = session.queue_message(
message,
attachment_ids=list(ordered_reserved),
queue_msg_id=send_id or None,
)
except AttachmentsNotQueueableError:
queue_outcome["rejected"] = "attachments_busy"
return
queue_outcome["cleaned"] = cleaned
queue_outcome["priority"] = priority
queue_outcome["msg_id"] = msg_id
@@ -2763,6 +2767,20 @@ def make_send_handler(cfg: SessionEndpointConfig) -> Handler:
}
)
if queue_outcome.get("rejected") == "attachments_busy":
# Attachments can't ride a queued user turn (see
# AttachmentsNotQueueableError for the role-ordering reason).
# Release reservations and surface to the caller so the
# client can hold the file and retry once the worker idles.
_release_reservation_on_fail()
return JSONResponse(
{
"status": "attachments_busy",
"attached_ids": [],
"dropped_attachment_ids": list(requested_ids),
}
)
dropped = [aid for aid in requested_ids if aid not in reserved_set]
if queue_outcome:
# Reused a live worker; ``queue_message`` succeeded.
@@ -3023,8 +3041,9 @@ def make_dequeue_handler(cfg: SessionEndpointConfig) -> Handler:
Removes a previously-queued message identified by ``msg_id`` from
the workstream's pending queue. Returns ``status: removed`` when
the queue had the entry and ``status: not_found`` otherwise.
Reservations attached to the dequeued message are released by
``ChatSession.dequeue_message`` so attachments can be reused.
Queued messages don't carry attachments (see
:class:`AttachmentsNotQueueableError`), so there's no reservation
side-effect to undo here.
"""
from turnstone.core.web_helpers import read_json_or_400
+9
View File
@@ -354,6 +354,15 @@
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.composer-attach:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.composer-attach:disabled:hover {
background: transparent;
color: var(--fg-dim);
border-color: var(--border-strong);
}
.composer-input {
flex: 1;
+13
View File
@@ -576,6 +576,19 @@
this.sendBtn.disabled = !!b && !opts.queueWhileBusy;
}
// Paperclip is disabled whenever busy, even in queueWhileBusy mode:
// attachments can't ride a queued user turn (would inject a `user`
// turn between assistant(tool_calls) and tool — see backend
// AttachmentsNotQueueableError).
if (this.attachBtn) {
this.attachBtn.disabled = !!b;
var attachLabel = b
? "Attach files (available once the current turn finishes)"
: "Attach files";
this.attachBtn.title = attachLabel;
this.attachBtn.setAttribute("aria-label", attachLabel);
}
// Stop button visibility + label reset — reset every transition so
// cancelGeneration's transient "Cancelling…" label doesn't stick.
if (this.stopBtn) {
+13
View File
@@ -1921,6 +1921,16 @@ Pane.prototype.sendMessage = function () {
} else if (data.status === "queue_full") {
if (queuedEl) self.queue.remove(queuedEl);
self.addErrorMessage("Message queue full. Please wait.");
} else if (data.status === "attachments_busy") {
// Attachments can't ride a queued user turn — server held the
// chips' reservations long enough to bounce the request and
// released them. Surface to the user; chips stay in the
// composer so they can retry once the assistant finishes.
if (queuedEl) self.queue.remove(queuedEl);
self.addErrorMessage(
"Attachments can't be sent while the assistant is working. " +
"Send a text-only message now, or wait and resend with attachments.",
);
} else {
self.attachments.consume(
data.attached_ids,
@@ -2678,6 +2688,9 @@ function showTabDropdown(chevronEl, wsId) {
menu.style.top = my + "px";
_tabDropdown = menu;
// Keyboard handler is mirrored by the console node-picker shim in
// turnstone/console/server.py (search for closeHandler in _JS_PROXY_SHIM).
// If you change the keys or filter selector here, change them there.
_tabDropdownCloseHandler = function (e) {
if (e.type === "keydown") {
if (e.key === "Escape" || e.key === "Tab") {
Generated
+112 -112
View File
@@ -598,16 +598,16 @@ wheels = [
[[package]]
name = "ddgs"
version = "9.14.1"
version = "9.14.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "lxml" },
{ name = "primp" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/f2/aa1f5af106ea0ef0351d11a2fe05d28618463160137326eeb3073b7d788b/ddgs-9.14.1.tar.gz", hash = "sha256:85b878225a622ba145aff33c0f2f0dceb90d6cfaa291af253021d10cb261a8bb", size = 57157, upload-time = "2026-04-20T12:09:21.313Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e6/31/4b8ad86fd97fba7cff52d9d7c59a002ddf9ef0ba8fa4d70b925190471c33/ddgs-9.14.2.tar.gz", hash = "sha256:a9e6ad5bd7357707163d1cf03dbbcc9413a5820738ba5176efe36955b32aab38", size = 57205, upload-time = "2026-05-03T19:45:30.229Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4f/a0/c0b568acd6ec819ec94ecfd4eebd00edc855efab06e589ad17d0412ff4ce/ddgs-9.14.1-py3-none-any.whl", hash = "sha256:e6b853be092532add9c0d611c4b121f0b27092de66756401057c2100f6b1ab44", size = 67019, upload-time = "2026-04-20T12:09:19.867Z" },
{ url = "https://files.pythonhosted.org/packages/94/e6/5d258f7bfb418a5d33c3a77fba327efd8bd6c5d834d06f07b4f229033c33/ddgs-9.14.2-py3-none-any.whl", hash = "sha256:47f5002ebe72d0e7d342d9ce9c0cd9d1125fa7b9ee38dc47069449f4a8382d37", size = 67058, upload-time = "2026-05-03T19:45:28.693Z" },
]
[[package]]
@@ -748,59 +748,59 @@ wheels = [
[[package]]
name = "greenlet"
version = "3.4.0"
version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/86/94/a5935717b307d7c71fe877b52b884c6af707d2d2090db118a03fbd799369/greenlet-3.4.0.tar.gz", hash = "sha256:f50a96b64dafd6169e595a5c56c9146ef80333e67d4476a65a9c55f400fc22ff", size = 195913, upload-time = "2026-04-08T17:08:00.863Z" }
sdist = { url = "https://files.pythonhosted.org/packages/3c/3f/dbf99fb14bfeb88c28f16729215478c0e265cacd6dc22270c8f31bb6892f/greenlet-3.5.0.tar.gz", hash = "sha256:d419647372241bc68e957bf38d5c1f98852155e4146bd1e4121adea81f4f01e4", size = 196995, upload-time = "2026-04-27T13:37:15.544Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/c6/dba32cab7e3a625b011aa5647486e2d28423a48845a2998c126dd69c85e1/greenlet-3.4.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:805bebb4945094acbab757d34d6e1098be6de8966009ab9ca54f06ff492def58", size = 285504, upload-time = "2026-04-08T15:52:14.071Z" },
{ url = "https://files.pythonhosted.org/packages/54/f4/7cb5c2b1feb9a1f50e038be79980dfa969aa91979e5e3a18fdbcfad2c517/greenlet-3.4.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:439fc2f12b9b512d9dfa681c5afe5f6b3232c708d13e6f02c845e0d9f4c2d8c6", size = 605476, upload-time = "2026-04-08T16:24:37.064Z" },
{ url = "https://files.pythonhosted.org/packages/d6/af/b66ab0b2f9a4c5a867c136bf66d9599f34f21a1bcca26a2884a29c450bd9/greenlet-3.4.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a70ed1cb0295bee1df57b63bf7f46b4e56a5c93709eea769c1fec1bb23a95875", size = 618336, upload-time = "2026-04-08T16:30:56.59Z" },
{ url = "https://files.pythonhosted.org/packages/6d/31/56c43d2b5de476f77d36ceeec436328533bff960a4cba9a07616e93063ab/greenlet-3.4.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8c5696c42e6bb5cfb7c6ff4453789081c66b9b91f061e5e9367fa15792644e76", size = 625045, upload-time = "2026-04-08T16:40:37.111Z" },
{ url = "https://files.pythonhosted.org/packages/e5/5c/8c5633ece6ba611d64bf2770219a98dd439921d6424e4e8cf16b0ac74ea5/greenlet-3.4.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c660bce1940a1acae5f51f0a064f1bc785d07ea16efcb4bc708090afc4d69e83", size = 613515, upload-time = "2026-04-08T15:56:32.478Z" },
{ url = "https://files.pythonhosted.org/packages/80/ca/704d4e2c90acb8bdf7ae593f5cbc95f58e82de95cc540fb75631c1054533/greenlet-3.4.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:89995ce5ddcd2896d89615116dd39b9703bfa0c07b583b85b89bf1b5d6eddf81", size = 419745, upload-time = "2026-04-08T16:43:04.022Z" },
{ url = "https://files.pythonhosted.org/packages/a9/df/950d15bca0d90a0e7395eb777903060504cdb509b7b705631e8fb69ff415/greenlet-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ee407d4d1ca9dc632265aee1c8732c4a2d60adff848057cdebfe5fe94eb2c8a2", size = 1574623, upload-time = "2026-04-08T16:26:18.596Z" },
{ url = "https://files.pythonhosted.org/packages/1a/e7/0839afab829fcb7333c9ff6d80c040949510055d2d4d63251f0d1c7c804e/greenlet-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:956215d5e355fffa7c021d168728321fd4d31fd730ac609b1653b450f6a4bc71", size = 1639579, upload-time = "2026-04-08T15:57:29.231Z" },
{ url = "https://files.pythonhosted.org/packages/d9/2b/b4482401e9bcaf9f5c97f67ead38db89c19520ff6d0d6699979c6efcc200/greenlet-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:5cb614ace7c27571270354e9c9f696554d073f8aa9319079dcba466bbdead711", size = 238233, upload-time = "2026-04-08T17:02:54.286Z" },
{ url = "https://files.pythonhosted.org/packages/0c/4d/d8123a4e0bcd583d5cfc8ddae0bbe29c67aab96711be331a7cc935a35966/greenlet-3.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:04403ac74fe295a361f650818de93be11b5038a78f49ccfb64d3b1be8fbf1267", size = 235045, upload-time = "2026-04-08T17:04:05.072Z" },
{ url = "https://files.pythonhosted.org/packages/65/8b/3669ad3b3f247a791b2b4aceb3aa5a31f5f6817bf547e4e1ff712338145a/greenlet-3.4.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:1a54a921561dd9518d31d2d3db4d7f80e589083063ab4d3e2e950756ef809e1a", size = 286902, upload-time = "2026-04-08T15:52:12.138Z" },
{ url = "https://files.pythonhosted.org/packages/38/3e/3c0e19b82900873e2d8469b590a6c4b3dfd2b316d0591f1c26b38a4879a5/greenlet-3.4.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16dec271460a9a2b154e3b1c2fa1050ce6280878430320e85e08c166772e3f97", size = 606099, upload-time = "2026-04-08T16:24:38.408Z" },
{ url = "https://files.pythonhosted.org/packages/b5/33/99fef65e7754fc76a4ed14794074c38c9ed3394a5bd129d7f61b705f3168/greenlet-3.4.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:90036ce224ed6fe75508c1907a77e4540176dcf0744473627785dd519c6f9996", size = 618837, upload-time = "2026-04-08T16:30:58.298Z" },
{ url = "https://files.pythonhosted.org/packages/44/57/eae2cac10421feae6c0987e3dc106c6d86262b1cb379e171b017aba893a6/greenlet-3.4.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6f0def07ec9a71d72315cf26c061aceee53b306c36ed38c35caba952ea1b319d", size = 624901, upload-time = "2026-04-08T16:40:38.981Z" },
{ url = "https://files.pythonhosted.org/packages/36/f7/229f3aed6948faa20e0616a0b8568da22e365ede6a54d7d369058b128afd/greenlet-3.4.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1c4f6b453006efb8310affb2d132832e9bbb4fc01ce6df6b70d810d38f1f6dc", size = 615062, upload-time = "2026-04-08T15:56:33.766Z" },
{ url = "https://files.pythonhosted.org/packages/6a/8a/0e73c9b94f31d1cc257fe79a0eff621674141cdae7d6d00f40de378a1e42/greenlet-3.4.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:0e1254cf0cbaa17b04320c3a78575f29f3c161ef38f59c977108f19ffddaf077", size = 423927, upload-time = "2026-04-08T16:43:05.293Z" },
{ url = "https://files.pythonhosted.org/packages/08/97/d988180011aa40135c46cd0d0cf01dd97f7162bae14139b4a3ef54889ba5/greenlet-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b2d9a138ffa0e306d0e2b72976d2fb10b97e690d40ab36a472acaab0838e2de", size = 1573511, upload-time = "2026-04-08T16:26:20.058Z" },
{ url = "https://files.pythonhosted.org/packages/d4/0f/a5a26fe152fb3d12e6a474181f6e9848283504d0afd095f353d85726374b/greenlet-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8424683caf46eb0eb6f626cb95e008e8cc30d0cb675bdfa48200925c79b38a08", size = 1640396, upload-time = "2026-04-08T15:57:30.88Z" },
{ url = "https://files.pythonhosted.org/packages/42/cf/bb2c32d9a100e36ee9f6e38fad6b1e082b8184010cb06259b49e1266ca01/greenlet-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0a53fb071531d003b075c444014ff8f8b1a9898d36bb88abd9ac7b3524648a2", size = 238892, upload-time = "2026-04-08T17:03:10.094Z" },
{ url = "https://files.pythonhosted.org/packages/b7/47/6c41314bac56e71436ce551c7fbe3cc830ed857e6aa9708dbb9c65142eb6/greenlet-3.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:f38b81880ba28f232f1f675893a39cf7b6db25b31cc0a09bb50787ecf957e85e", size = 235599, upload-time = "2026-04-08T15:52:54.3Z" },
{ url = "https://files.pythonhosted.org/packages/7a/75/7e9cd1126a1e1f0cd67b0eda02e5221b28488d352684704a78ed505bd719/greenlet-3.4.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:43748988b097f9c6f09364f260741aa73c80747f63389824435c7a50bfdfd5c1", size = 285856, upload-time = "2026-04-08T15:52:45.82Z" },
{ url = "https://files.pythonhosted.org/packages/9d/c4/3e2df392e5cb199527c4d9dbcaa75c14edcc394b45040f0189f649631e3c/greenlet-3.4.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5566e4e2cd7a880e8c27618e3eab20f3494452d12fd5129edef7b2f7aa9a36d1", size = 610208, upload-time = "2026-04-08T16:24:39.674Z" },
{ url = "https://files.pythonhosted.org/packages/da/af/750cdfda1d1bd30a6c28080245be8d0346e669a98fdbae7f4102aa95fff3/greenlet-3.4.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1054c5a3c78e2ab599d452f23f7adafef55062a783a8e241d24f3b633ba6ff82", size = 621269, upload-time = "2026-04-08T16:30:59.767Z" },
{ url = "https://files.pythonhosted.org/packages/e0/93/c8c508d68ba93232784bbc1b5474d92371f2897dfc6bc281b419f2e0d492/greenlet-3.4.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:98eedd1803353daf1cd9ef23eef23eda5a4d22f99b1f998d273a8b78b70dd47f", size = 628455, upload-time = "2026-04-08T16:40:40.698Z" },
{ url = "https://files.pythonhosted.org/packages/54/78/0cbc693622cd54ebe25207efbb3a0eb07c2639cb8594f6e3aaaa0bb077a8/greenlet-3.4.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f82cb6cddc27dd81c96b1506f4aa7def15070c3b2a67d4e46fd19016aacce6cf", size = 617549, upload-time = "2026-04-08T15:56:34.893Z" },
{ url = "https://files.pythonhosted.org/packages/7f/46/cfaaa0ade435a60550fd83d07dfd5c41f873a01da17ede5c4cade0b9bab8/greenlet-3.4.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:b7857e2202aae67bc5725e0c1f6403c20a8ff46094ece015e7d474f5f7020b55", size = 426238, upload-time = "2026-04-08T16:43:06.865Z" },
{ url = "https://files.pythonhosted.org/packages/ba/c0/8966767de01343c1ff47e8b855dc78e7d1a8ed2b7b9c83576a57e289f81d/greenlet-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:227a46251ecba4ff46ae742bc5ce95c91d5aceb4b02f885487aff269c127a729", size = 1575310, upload-time = "2026-04-08T16:26:21.671Z" },
{ url = "https://files.pythonhosted.org/packages/b8/38/bcdc71ba05e9a5fda87f63ffc2abcd1f15693b659346df994a48c968003d/greenlet-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5b99e87be7eba788dd5b75ba1cde5639edffdec5f91fe0d734a249535ec3408c", size = 1640435, upload-time = "2026-04-08T15:57:32.572Z" },
{ url = "https://files.pythonhosted.org/packages/a1/c2/19b664b7173b9e4ef5f77e8cef9f14c20ec7fce7920dc1ccd7afd955d093/greenlet-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:849f8bc17acd6295fcb5de8e46d55cc0e52381c56eaf50a2afd258e97bc65940", size = 238760, upload-time = "2026-04-08T17:04:03.878Z" },
{ url = "https://files.pythonhosted.org/packages/9b/96/795619651d39c7fbd809a522f881aa6f0ead504cc8201c3a5b789dfaef99/greenlet-3.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:9390ad88b652b1903814eaabd629ca184db15e0eeb6fe8a390bbf8b9106ae15a", size = 235498, upload-time = "2026-04-08T17:05:00.584Z" },
{ url = "https://files.pythonhosted.org/packages/78/02/bde66806e8f169cf90b14d02c500c44cdbe02c8e224c9c67bafd1b8cadd1/greenlet-3.4.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:10a07aca6babdd18c16a3f4f8880acfffc2b88dfe431ad6aa5f5740759d7d75e", size = 286291, upload-time = "2026-04-08T17:09:34.307Z" },
{ url = "https://files.pythonhosted.org/packages/05/1f/39da1c336a87d47c58352fb8a78541ce63d63ae57c5b9dae1fe02801bbc2/greenlet-3.4.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:076e21040b3a917d3ce4ad68fb5c3c6b32f1405616c4a57aa83120979649bd3d", size = 656749, upload-time = "2026-04-08T16:24:41.721Z" },
{ url = "https://files.pythonhosted.org/packages/d3/6c/90ee29a4ee27af7aa2e2ec408799eeb69ee3fcc5abcecac6ddd07a5cd0f2/greenlet-3.4.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e82689eea4a237e530bb5cb41b180ef81fa2160e1f89422a67be7d90da67f615", size = 669084, upload-time = "2026-04-08T16:31:01.372Z" },
{ url = "https://files.pythonhosted.org/packages/d2/4a/74078d3936712cff6d3c91a930016f476ce4198d84e224fe6d81d3e02880/greenlet-3.4.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:06c2d3b89e0c62ba50bd7adf491b14f39da9e7e701647cb7b9ff4c99bee04b19", size = 673405, upload-time = "2026-04-08T16:40:42.527Z" },
{ url = "https://files.pythonhosted.org/packages/07/49/d4cad6e5381a50947bb973d2f6cf6592621451b09368b8c20d9b8af49c5b/greenlet-3.4.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4df3b0b2289ec686d3c821a5fee44259c05cfe824dd5e6e12c8e5f5df23085cf", size = 665621, upload-time = "2026-04-08T15:56:35.995Z" },
{ url = "https://files.pythonhosted.org/packages/79/3e/df8a83ab894751bc31e1106fdfaa80ca9753222f106b04de93faaa55feb7/greenlet-3.4.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:070b8bac2ff3b4d9e0ff36a0d19e42103331d9737e8504747cd1e659f76297bd", size = 471670, upload-time = "2026-04-08T16:43:08.512Z" },
{ url = "https://files.pythonhosted.org/packages/37/31/d1edd54f424761b5d47718822f506b435b6aab2f3f93b465441143ea5119/greenlet-3.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8bff29d586ea415688f4cec96a591fcc3bf762d046a796cdadc1fdb6e7f2d5bf", size = 1622259, upload-time = "2026-04-08T16:26:23.201Z" },
{ url = "https://files.pythonhosted.org/packages/b0/c6/6d3f9cdcb21c4e12a79cb332579f1c6aa1af78eb68059c5a957c7812d95e/greenlet-3.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a569c2fb840c53c13a2b8967c63621fafbd1a0e015b9c82f408c33d626a2fda", size = 1686916, upload-time = "2026-04-08T15:57:34.282Z" },
{ url = "https://files.pythonhosted.org/packages/63/45/c1ca4a1ad975de4727e52d3ffe641ae23e1d7a8ffaa8ff7a0477e1827b92/greenlet-3.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:207ba5b97ea8b0b60eb43ffcacf26969dd83726095161d676aac03ff913ee50d", size = 239821, upload-time = "2026-04-08T17:03:48.423Z" },
{ url = "https://files.pythonhosted.org/packages/71/c4/6f621023364d7e85a4769c014c8982f98053246d142420e0328980933ceb/greenlet-3.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:f8296d4e2b92af34ebde81085a01690f26a51eb9ac09a0fcadb331eb36dbc802", size = 236932, upload-time = "2026-04-08T17:04:33.551Z" },
{ url = "https://files.pythonhosted.org/packages/d4/8f/18d72b629783f5e8d045a76f5325c1e938e659a9e4da79c7dcd10169a48d/greenlet-3.4.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d70012e51df2dbbccfaf63a40aaf9b40c8bed37c3e3a38751c926301ce538ece", size = 294681, upload-time = "2026-04-08T15:52:35.778Z" },
{ url = "https://files.pythonhosted.org/packages/9e/ad/5fa86ec46769c4153820d58a04062285b3b9e10ba3d461ee257b68dcbf53/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a58bec0751f43068cd40cff31bb3ca02ad6000b3a51ca81367af4eb5abc480c8", size = 658899, upload-time = "2026-04-08T16:24:43.32Z" },
{ url = "https://files.pythonhosted.org/packages/43/f0/4e8174ca0e87ae748c409f055a1ba161038c43cc0a5a6f1433a26ac2e5bf/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05fa0803561028f4b2e3b490ee41216a842eaee11aed004cc343a996d9523aa2", size = 665284, upload-time = "2026-04-08T16:31:02.833Z" },
{ url = "https://files.pythonhosted.org/packages/ef/92/466b0d9afd44b8af623139a3599d651c7564fa4152f25f117e1ee5949ffb/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c4cd56a9eb7a6444edbc19062f7b6fbc8f287c663b946e3171d899693b1c19fa", size = 665872, upload-time = "2026-04-08T16:40:43.912Z" },
{ url = "https://files.pythonhosted.org/packages/19/da/991cf7cd33662e2df92a1274b7eb4d61769294d38a1bba8a45f31364845e/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e60d38719cb80b3ab5e85f9f1aed4960acfde09868af6762ccb27b260d68f4ed", size = 661861, upload-time = "2026-04-08T15:56:37.269Z" },
{ url = "https://files.pythonhosted.org/packages/0d/14/3395a7ef3e260de0325152ddfe19dffb3e49fe10873b94654352b53ad48e/greenlet-3.4.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:1f85f204c4d54134ae850d401fa435c89cd667d5ce9dc567571776b45941af72", size = 489237, upload-time = "2026-04-08T16:43:09.993Z" },
{ url = "https://files.pythonhosted.org/packages/36/c5/6c2c708e14db3d9caea4b459d8464f58c32047451142fe2cfd90e7458f41/greenlet-3.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f50c804733b43eded05ae694691c9aa68bca7d0a867d67d4a3f514742a2d53f", size = 1622182, upload-time = "2026-04-08T16:26:24.777Z" },
{ url = "https://files.pythonhosted.org/packages/7a/4c/50c5fed19378e11a29fabab1f6be39ea95358f4a0a07e115a51ca93385d8/greenlet-3.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2d4f0635dc4aa638cda4b2f5a07ae9a2cff9280327b581a3fcb6f317b4fbc38a", size = 1685050, upload-time = "2026-04-08T15:57:36.453Z" },
{ url = "https://files.pythonhosted.org/packages/db/72/85ae954d734703ab48e622c59d4ce35d77ce840c265814af9c078cacc7aa/greenlet-3.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1a4a48f24681300c640f143ba7c404270e1ebbbcf34331d7104a4ff40f8ea705", size = 245554, upload-time = "2026-04-08T17:03:50.044Z" },
{ url = "https://files.pythonhosted.org/packages/8b/0f/a91f143f356523ff682309732b175765a9bc2836fd7c081c2c67fedc1ad4/greenlet-3.5.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:8f1cc966c126639cd152fdaa52624d2655f492faa79e013fea161de3e6dda082", size = 284726, upload-time = "2026-04-27T12:20:51.402Z" },
{ url = "https://files.pythonhosted.org/packages/95/82/800646c7ffc5dbabd75ddd2f6b519bb898c0c9c969e5d0473bfe5d20bcce/greenlet-3.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:362624e6a8e5bca3b8233e45eef33903a100e9539a2b995c364d595dbc4018b3", size = 604264, upload-time = "2026-04-27T12:52:39.494Z" },
{ url = "https://files.pythonhosted.org/packages/ca/ac/354867c0bba812fc33b15bc55aedafedd0aee3c7dd91dfca22444157dc0c/greenlet-3.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5ecd83806b0f4c2f53b1018e0005cd82269ea01d42befc0368730028d850ed1c", size = 616099, upload-time = "2026-04-27T12:59:39.623Z" },
{ url = "https://files.pythonhosted.org/packages/c9/ab/192090c4a5b30df148c22bf4b8895457d739a7c7c5a7b9c41e5dd7f537f2/greenlet-3.5.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa94cb2288681e3a11645958f1871d48ee9211bd2f66628fdace505927d6e564", size = 623976, upload-time = "2026-04-27T13:02:37.363Z" },
{ url = "https://files.pythonhosted.org/packages/ff/b0/815bece7399e01cadb69014219eebd0042339875c59a59b0820a46ece356/greenlet-3.5.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ff251e9a0279522e62f6176412869395a64ddf2b5c5f782ff609a8216a4e662", size = 615198, upload-time = "2026-04-27T12:25:25.928Z" },
{ url = "https://files.pythonhosted.org/packages/24/11/05eb2b9b188c6df7d68a89c99134d644a7af616a40b9808e8e6ced315d5d/greenlet-3.5.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:64d6ac45f7271f48e45f67c95b54ef73534c52ec041fcda8edf520c6d811f4bc", size = 418379, upload-time = "2026-04-27T13:05:12.755Z" },
{ url = "https://files.pythonhosted.org/packages/10/80/3b2c0a895d6698f6ddb31b07942ebfa982f3e30888bc5546a5b5990de8b2/greenlet-3.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d874e79afd41a96e11ff4c5d0bc90a80973e476fda1c2c64985667397df432b", size = 1574927, upload-time = "2026-04-27T12:53:25.81Z" },
{ url = "https://files.pythonhosted.org/packages/44/0e/f354af514a4c61454dbc68e44d47544a5a4d6317e30b77ddfa3a09f4c5f3/greenlet-3.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0ed006e4b86c59de7467eb2601cd1b77b5a7d657d1ee55e30fe30d76451edba4", size = 1642683, upload-time = "2026-04-27T12:25:23.9Z" },
{ url = "https://files.pythonhosted.org/packages/fa/6a/87f38255201e993a1915265ebb80cd7c2c78b04a45744995abbf6b259fd8/greenlet-3.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:703cb211b820dbffbbc55a16bfc6e4583a6e6e990f33a119d2cc8b83211119c8", size = 238115, upload-time = "2026-04-27T12:21:48.845Z" },
{ url = "https://files.pythonhosted.org/packages/e3/f8/450fe3c5938fa737ea4d22699772e6e34e8e24431a47bf4e8a1ceed4a98e/greenlet-3.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:6c18dfb59c70f5a94acd271c72e90128c3c776e41e5f07767908c8c1b74ad339", size = 235017, upload-time = "2026-04-27T12:22:26.768Z" },
{ url = "https://files.pythonhosted.org/packages/ef/32/f2ce6d4cac3e55bc6173f92dbe627e782e1850f89d986c3606feb63aafa7/greenlet-3.5.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:db2910d3c809444e0a20147361f343fe2798e106af8d9d8506f5305302655a9f", size = 286228, upload-time = "2026-04-27T12:20:34.421Z" },
{ url = "https://files.pythonhosted.org/packages/b7/aa/caed9e5adf742315fc7be2a84196373aab4816e540e38ba0d76cb7584d68/greenlet-3.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ec9ea74e7268ace7f9aab1b1a4e730193fc661b39a993cd91c606c32d4a3628", size = 601775, upload-time = "2026-04-27T12:52:41.045Z" },
{ url = "https://files.pythonhosted.org/packages/c7/af/90ae08497400a941595d12774447f752d3dfe0fbb012e35b76bc5c0ff37e/greenlet-3.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54d243512da35485fc7a6bf3c178fdda6327a9d6506fcdd62b1abd1e41b2927b", size = 614436, upload-time = "2026-04-27T12:59:41.595Z" },
{ url = "https://files.pythonhosted.org/packages/3f/e9/4eeadf8cb3403ac274245ba75f07844abc7fa5f6787583fc9156ba741e0f/greenlet-3.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:41353ec2ecedf7aa8f682753a41919f8718031a6edac46b8d3dc7ed9e1ceb136", size = 620610, upload-time = "2026-04-27T13:02:39.194Z" },
{ url = "https://files.pythonhosted.org/packages/2b/e0/2e13df68f367e2f9960616927d60857dd7e56aaadd59a47c644216b2f920/greenlet-3.5.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d280a7f5c331622c69f97eb167f33577ff2d1df282c41cd15907fc0a3ca198c", size = 611388, upload-time = "2026-04-27T12:25:28.008Z" },
{ url = "https://files.pythonhosted.org/packages/ee/ef/f913b3c0eb7d26d86a2401c5e1546c9d46b657efee724b06f6f4ac5d8824/greenlet-3.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:58c1c374fe2b3d852f9b6b11a7dff4c85404e51b9a596fd9e89cf904eb09866d", size = 422775, upload-time = "2026-04-27T13:05:14.261Z" },
{ url = "https://files.pythonhosted.org/packages/82/f7/393c64055132ac0d488ef6be549253b7e6274194863967ddc0bc8f5b87b8/greenlet-3.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1eb67d5adefb5bd2e182d42678a328979a209e4e82eb93575708185d31d1f588", size = 1570768, upload-time = "2026-04-27T12:53:28.099Z" },
{ url = "https://files.pythonhosted.org/packages/b8/4b/eaf7735253522cf56d1b74d672a58f54fc114702ceaf05def59aae72f6e1/greenlet-3.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2628d6c86f6cb0cb45e0c3c54058bbec559f57eaae699447748cb3928150577e", size = 1635983, upload-time = "2026-04-27T12:25:26.903Z" },
{ url = "https://files.pythonhosted.org/packages/4c/fe/4fb3a0805bd5165da5ebf858da7cc01cce8061674106d2cf5bdab32cbfde/greenlet-3.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:d4d9f0624c775f2dfc56ba54d515a8c771044346852a918b405914f6b19d7fd8", size = 238840, upload-time = "2026-04-27T12:23:54.806Z" },
{ url = "https://files.pythonhosted.org/packages/cb/cb/baa584cb00532126ffe12d9787db0a60c5a4f55c27bfe2666df5d4c30a32/greenlet-3.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:83ed9f27f1680b50e89f40f6df348a290ea234b249a4003d366663a12eab94f2", size = 235615, upload-time = "2026-04-27T12:21:38.57Z" },
{ url = "https://files.pythonhosted.org/packages/0c/58/fc576f99037ce19c5aa16628e4c3226b6d1419f72a62c79f5f40576e6eb3/greenlet-3.5.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:5a5ed18de6a0f6cc7087f1563f6bd93fc7df1c19165ca01e9bde5a5dc281d106", size = 285066, upload-time = "2026-04-27T12:23:05.033Z" },
{ url = "https://files.pythonhosted.org/packages/4a/ba/b28ddbe6bfad6a8ac196ef0e8cff37bc65b79735995b9e410923fffeeb70/greenlet-3.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a717fbc46d8a354fa675f7c1e813485b6ba3885f9bef0cd56e5ba27d758ff5b", size = 604414, upload-time = "2026-04-27T12:52:42.358Z" },
{ url = "https://files.pythonhosted.org/packages/09/06/4b69f8f0b67603a8be2790e55107a190b376f2627fe0eaf5695d85ffb3cd/greenlet-3.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ddc090c5c1792b10246a78e8c2163ebbe04cf877f9d785c230a7b27b39ad038e", size = 617349, upload-time = "2026-04-27T12:59:43.32Z" },
{ url = "https://files.pythonhosted.org/packages/6a/15/a643b4ecd09969e30b8a150d5919960caae0abe4f5af75ab040b1ab85e78/greenlet-3.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4964101b8585c144cbda5532b1aa644255126c08a265dae90c16e7a0e63aaa9d", size = 623234, upload-time = "2026-04-27T13:02:40.611Z" },
{ url = "https://files.pythonhosted.org/packages/8a/17/a3918541fd0ddefe024a69de6d16aa7b46d36ac19562adaa63c7fa180eff/greenlet-3.5.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2094acd54b272cb6eae8c03dd87b3fa1820a4cef18d6889c378d503500a1dc13", size = 613927, upload-time = "2026-04-27T12:25:30.28Z" },
{ url = "https://files.pythonhosted.org/packages/77/18/3b13d5ef1275b0ffaf933b05efa21408ac4ca95823c7411d79682e4fdcff/greenlet-3.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:7022615368890680e67b9965d33f5773aade330d5343bbe25560135aaa849eae", size = 425243, upload-time = "2026-04-27T13:05:15.689Z" },
{ url = "https://files.pythonhosted.org/packages/ee/e1/bd0af6213c7dd33175d8a462d4c1fe1175124ebed4855bc1475a5b5242c2/greenlet-3.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5e05ba267789ea87b5a155cf0e810b1ab88bf18e9e8740813945ceb8ee4350ba", size = 1570893, upload-time = "2026-04-27T12:53:29.483Z" },
{ url = "https://files.pythonhosted.org/packages/9b/2a/0789702f864f5382cb476b93d7a9c823c10472658102ccd65f415747d2e2/greenlet-3.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0ecec963079cd58cbd14723582384f11f166fd58883c15dcbfb342e0bc9b5846", size = 1636060, upload-time = "2026-04-27T12:25:28.845Z" },
{ url = "https://files.pythonhosted.org/packages/b2/8f/22bf9df92bbff0eb07842b60f7e63bf7675a9742df628437a9f02d09137f/greenlet-3.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:728d9667d8f2f586644b748dbd9bb67e50d6a9381767d1357714ea6825bb3bf5", size = 238740, upload-time = "2026-04-27T12:24:01.341Z" },
{ url = "https://files.pythonhosted.org/packages/b6/b7/9c5c3d653bd4ff614277c049ac676422e2c557db47b4fe43e6313fc005dc/greenlet-3.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:47422135b1d308c14b2c6e758beedb1acd33bb91679f5670edf77bf46244722b", size = 235525, upload-time = "2026-04-27T12:23:12.308Z" },
{ url = "https://files.pythonhosted.org/packages/94/5e/a70f31e3e8d961c4ce589c15b28e4225d63704e431a23932a3808cbcc867/greenlet-3.5.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:f35807464c4c58c55f0d31dfa83c541a5615d825c2fe3d2b95360cf7c4e3c0a8", size = 285564, upload-time = "2026-04-27T12:23:08.555Z" },
{ url = "https://files.pythonhosted.org/packages/af/a6/046c0a28e21833e4086918218cfb3d8bed51c075a1b700f20b9d7861c0f4/greenlet-3.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55fa7ea52771be44af0de27d8b80c02cd18c2c3cddde6c847ecebdf72418b6a1", size = 651166, upload-time = "2026-04-27T12:52:43.644Z" },
{ url = "https://files.pythonhosted.org/packages/47/f8/4af27f71c5ff32a7fbc516adb46370d9c4ae2bc7bd3dc7d066ac542b4b15/greenlet-3.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a97e4821aa710603f94de0da25f25096454d78ffdace5dc77f3a006bc01abba3", size = 663792, upload-time = "2026-04-27T12:59:44.93Z" },
{ url = "https://files.pythonhosted.org/packages/fb/89/2dadb89793c37ee8b4c237857188293e9060dc085f19845c292e00f8e091/greenlet-3.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bf2d8a80bec89ab46221ae45c5373d5ba0bd36c19aa8508e85c6cd7e5106cd37", size = 668086, upload-time = "2026-04-27T13:02:42.314Z" },
{ url = "https://files.pythonhosted.org/packages/a3/59/1bd6d7428d6ed9106efbb8c52310c60fd04f6672490f452aeaa3829aa436/greenlet-3.5.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f52a464e4ed91780bdfbbdd2b97197f3accaa629b98c200f4dffada759f3ae7", size = 660933, upload-time = "2026-04-27T12:25:33.276Z" },
{ url = "https://files.pythonhosted.org/packages/82/35/75722be7e26a2af4cbd2dc35b0ed382dacf9394b7e75551f76ed1abe87f2/greenlet-3.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:1bae92a1dd94c5f9d9493c3a212dd874c202442047cf96446412c862feca83a2", size = 470799, upload-time = "2026-04-27T13:05:17.094Z" },
{ url = "https://files.pythonhosted.org/packages/83/e4/b903e5a5fae1e8a28cdd32a0cfbfd560b668c25b692f67768822ddc5f40f/greenlet-3.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:762612baf1161ccb8437c0161c668a688223cba28e1bf038f4eb47b13e39ccdf", size = 1618401, upload-time = "2026-04-27T12:53:31.062Z" },
{ url = "https://files.pythonhosted.org/packages/0e/e3/5ec408a329acb854fb607a122e1ee5fb3ff649f9a97952948a90803c0d8e/greenlet-3.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:57a43c6079a89713522bc4bcb9f75070ecf5d3dbad7792bfe42239362cbf2a16", size = 1682038, upload-time = "2026-04-27T12:25:31.838Z" },
{ url = "https://files.pythonhosted.org/packages/91/20/6b165108058767ee643c55c5c4904d591a830ee2b3c7dbd359828fbc829f/greenlet-3.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bc59be3945ae9750b9e7d45067d01ae3fe90ea5f9ade99239dabdd6e28a5033", size = 239835, upload-time = "2026-04-27T12:24:54.136Z" },
{ url = "https://files.pythonhosted.org/packages/4e/62/1c498375cee177b55d980c1db319f26470e5309e54698c8f8fc06c0fd539/greenlet-3.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:a96fcee45e03fe30a62669fd16ab5c9d3c172660d3085605cb1e2d1280d3c988", size = 236862, upload-time = "2026-04-27T12:23:24.957Z" },
{ url = "https://files.pythonhosted.org/packages/78/a8/4522939255bb5409af4e87132f915446bf3622c2c292d14d3c38d128ae82/greenlet-3.5.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:a10a732421ab4fec934783ce3e54763470d0181db6e3468f9103a275c3ed1853", size = 293614, upload-time = "2026-04-27T12:24:12.874Z" },
{ url = "https://files.pythonhosted.org/packages/15/5e/8744c52e2c027b5a8772a01561934c8835f869733e101f62075c60430340/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fc391b1566f2907d17aaebe78f8855dc45675159a775fcf9e61f8ee0078e87f", size = 650723, upload-time = "2026-04-27T12:52:45.412Z" },
{ url = "https://files.pythonhosted.org/packages/00/ef/7b4c39c03cf46ceca512c5d3f914afd85aa30b2cc9a93015b0dd73e4be6c/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:680bd0e7ad5e8daa8a4aa89f68fd6adc834b8a8036dc256533f7e08f4a4b01f7", size = 656529, upload-time = "2026-04-27T12:59:46.295Z" },
{ url = "https://files.pythonhosted.org/packages/5f/5c/0602239503b124b70e39355cbdb39361ecfe65b87a5f2f63752c32f5286f/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1aa4ce8debcd4ea7fb2e150f3036588c41493d1d52c43538924ae1819003f4ce", size = 657015, upload-time = "2026-04-27T13:02:43.973Z" },
{ url = "https://files.pythonhosted.org/packages/0b/b5/c7768f352f5c010f92064d0063f987e7dc0cd290a6d92a34109015ce4aa1/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ddb36c7d6c9c0a65f18c7258634e0c416c6ab59caac8c987b96f80c2ebda0112", size = 654364, upload-time = "2026-04-27T12:25:35.64Z" },
{ url = "https://files.pythonhosted.org/packages/38/51/8699f865f125dc952384cb432b0f7138aa4d8f2969a7d12d0df5b94d054d/greenlet-3.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:728a73687e39ae9ca34e4694cbf2f049d3fbc7174639468d0f67200a97d8f9e2", size = 488275, upload-time = "2026-04-27T13:05:18.28Z" },
{ url = "https://files.pythonhosted.org/packages/ef/d0/079ebe12e4b1fc758857ce5be1a5e73f06870f2101e52611d1e71925ce54/greenlet-3.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e5ddf316ced87539144621453c3aef229575825fe60c604e62bedc4003f372b2", size = 1614204, upload-time = "2026-04-27T12:53:32.618Z" },
{ url = "https://files.pythonhosted.org/packages/6d/89/6c2fb63df3596552d20e58fb4d96669243388cf680cff222758812c7bfaa/greenlet-3.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4a448128607be0de65342dc9b31be7f948ef4cc0bc8832069350abefd310a8f2", size = 1675480, upload-time = "2026-04-27T12:25:34.168Z" },
{ url = "https://files.pythonhosted.org/packages/15/32/77ee8a6c1564fc345a491a4e85b3bf360e4cf26eac98c4532d2fdb96e01f/greenlet-3.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d60097128cb0a1cab9ea541186ea13cd7b847b8449a7787c2e2350da0cb82d86", size = 245324, upload-time = "2026-04-27T12:24:40.295Z" },
]
[[package]]
@@ -1174,14 +1174,14 @@ wheels = [
[[package]]
name = "mako"
version = "1.3.11"
version = "1.3.12"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/59/8a/805404d0c0b9f3d7a326475ca008db57aea9c5c9f2e1e39ed0faa335571c/mako-1.3.11.tar.gz", hash = "sha256:071eb4ab4c5010443152255d77db7faa6ce5916f35226eb02dc34479b6858069", size = 399811, upload-time = "2026-04-14T20:19:51.493Z" }
sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/68/a5/19d7aaa7e433713ffe881df33705925a196afb9532efc8475d26593921a6/mako-1.3.11-py3-none-any.whl", hash = "sha256:e372c6e333cf004aa736a15f425087ec977e1fcbd2966aae7f17c8dc1da27a77", size = 78503, upload-time = "2026-04-14T20:19:53.233Z" },
{ url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" },
]
[[package]]
@@ -1549,7 +1549,7 @@ wheels = [
[[package]]
name = "openai"
version = "2.32.0"
version = "2.33.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@@ -1561,9 +1561,9 @@ dependencies = [
{ name = "tqdm" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ed/59/bdcc6b759b8c42dd73afaf5bf8f902c04b37987a5514dbc1c64dba390fef/openai-2.32.0.tar.gz", hash = "sha256:c54b27a9e4cb8d51f0dd94972ffd1a04437efeb259a9e60d8922b8bd26fe55e0", size = 693286, upload-time = "2026-04-15T22:28:19.434Z" }
sdist = { url = "https://files.pythonhosted.org/packages/f0/ee/d056c82f63c05f06baac0cffb4a90952d8274f90c49dfe244f20497b9bbd/openai-2.33.0.tar.gz", hash = "sha256:f850c435e2a4685bba3295bd54912dd26315d9c1b7733068186134d6e0599f9a", size = 693254, upload-time = "2026-04-28T14:04:42.428Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/c1/d6e64ccd0536bf616556f0cad2b6d94a8125f508d25cfd814b1d2db4e2f1/openai-2.32.0-py3-none-any.whl", hash = "sha256:4dcc9badeb4bf54ad0d187453742f290226d30150890b7890711bda4f32f192f", size = 1162570, upload-time = "2026-04-15T22:28:17.714Z" },
{ url = "https://files.pythonhosted.org/packages/7d/32/37734d769bc8b42e4938785313cc05aade6cb0fa72479d3220a0d61a4e78/openai-2.33.0-py3-none-any.whl", hash = "sha256:03ac37d70e8c9e3a8124214e3afa785e2cbc12e627fbd98177a086ef2fd87ad5", size = 1162695, upload-time = "2026-04-28T14:04:40.482Z" },
]
[[package]]
@@ -1732,15 +1732,15 @@ wheels = [
[[package]]
name = "psycopg"
version = "3.3.3"
version = "3.3.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d3/b6/379d0a960f8f435ec78720462fd94c4863e7a31237cf81bf76d0af5883bf/psycopg-3.3.3.tar.gz", hash = "sha256:5e9a47458b3c1583326513b2556a2a9473a1001a56c9efe9e587245b43148dd9", size = 165624, upload-time = "2026-02-18T16:52:16.546Z" }
sdist = { url = "https://files.pythonhosted.org/packages/db/2f/cb91e5502ec9de1de6f1b76cfbf69531932725361168bb06963620c77e2e/psycopg-3.3.4.tar.gz", hash = "sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc", size = 165799, upload-time = "2026-05-01T23:31:55.179Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/5b/181e2e3becb7672b502f0ed7f16ed7352aca7c109cfb94cf3878a9186db9/psycopg-3.3.3-py3-none-any.whl", hash = "sha256:f96525a72bcfade6584ab17e89de415ff360748c766f0106959144dcbb38c698", size = 212768, upload-time = "2026-02-18T16:46:27.365Z" },
{ url = "https://files.pythonhosted.org/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl", hash = "sha256:b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a", size = 213001, upload-time = "2026-05-01T23:20:50.816Z" },
]
[package.optional-dependencies]
@@ -1750,53 +1750,53 @@ binary = [
[[package]]
name = "psycopg-binary"
version = "3.3.3"
version = "3.3.4"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/be/c0/b389119dd754483d316805260f3e73cdcad97925839107cc7a296f6132b1/psycopg_binary-3.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a89bb9ee11177b2995d87186b1d9fa892d8ea725e85eab28c6525e4cc14ee048", size = 4609740, upload-time = "2026-02-18T16:47:51.093Z" },
{ url = "https://files.pythonhosted.org/packages/cf/e3/9976eef20f61840285174d360da4c820a311ab39d6b82fa09fbb545be825/psycopg_binary-3.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9f7d0cf072c6fbac3795b08c98ef9ea013f11db609659dcfc6b1f6cc31f9e181", size = 4676837, upload-time = "2026-02-18T16:47:55.523Z" },
{ url = "https://files.pythonhosted.org/packages/9f/f2/d28ba2f7404fd7f68d41e8a11df86313bd646258244cb12a8dd83b868a97/psycopg_binary-3.3.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:90eecd93073922f085967f3ed3a98ba8c325cbbc8c1a204e300282abd2369e13", size = 5497070, upload-time = "2026-02-18T16:47:59.929Z" },
{ url = "https://files.pythonhosted.org/packages/de/2f/6c5c54b815edeb30a281cfcea96dc93b3bb6be939aea022f00cab7aa1420/psycopg_binary-3.3.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dac7ee2f88b4d7bb12837989ca354c38d400eeb21bce3b73dac02622f0a3c8d6", size = 5172410, upload-time = "2026-02-18T16:48:05.665Z" },
{ url = "https://files.pythonhosted.org/packages/51/75/8206c7008b57de03c1ada46bd3110cc3743f3fd9ed52031c4601401d766d/psycopg_binary-3.3.3-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b62cf8784eb6d35beaee1056d54caf94ec6ecf2b7552395e305518ab61eb8fd2", size = 6763408, upload-time = "2026-02-18T16:48:13.541Z" },
{ url = "https://files.pythonhosted.org/packages/d4/5a/ea1641a1e6c8c8b3454b0fcb43c3045133a8b703e6e824fae134088e63bd/psycopg_binary-3.3.3-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a39f34c9b18e8f6794cca17bfbcd64572ca2482318db644268049f8c738f35a6", size = 5006255, upload-time = "2026-02-18T16:48:22.176Z" },
{ url = "https://files.pythonhosted.org/packages/aa/fb/538df099bf55ae1637d52d7ccb6b9620b535a40f4c733897ac2b7bb9e14c/psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:883d68d48ca9ff3cb3d10c5fdebea02c79b48eecacdddbf7cce6e7cdbdc216b8", size = 4532694, upload-time = "2026-02-18T16:48:27.338Z" },
{ url = "https://files.pythonhosted.org/packages/a1/d1/00780c0e187ea3c13dfc53bd7060654b2232cd30df562aac91a5f1c545ac/psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:cab7bc3d288d37a80aa8c0820033250c95e40b1c2b5c57cf59827b19c2a8b69d", size = 4222833, upload-time = "2026-02-18T16:48:31.221Z" },
{ url = "https://files.pythonhosted.org/packages/7a/34/a07f1ff713c51d64dc9f19f2c32be80299a2055d5d109d5853662b922cb4/psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:56c767007ca959ca32f796b42379fc7e1ae2ed085d29f20b05b3fc394f3715cc", size = 3952818, upload-time = "2026-02-18T16:48:35.869Z" },
{ url = "https://files.pythonhosted.org/packages/d3/67/d33f268a7759b4445f3c9b5a181039b01af8c8263c865c1be7a6444d4749/psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:da2f331a01af232259a21573a01338530c6016dcfad74626c01330535bcd8628", size = 4258061, upload-time = "2026-02-18T16:48:41.365Z" },
{ url = "https://files.pythonhosted.org/packages/b4/3b/0d8d2c5e8e29ccc07d28c8af38445d9d9abcd238d590186cac82ee71fc84/psycopg_binary-3.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:19f93235ece6dbfc4036b5e4f6d8b13f0b8f2b3eeb8b0bd2936d406991bcdd40", size = 3558915, upload-time = "2026-02-18T16:48:46.679Z" },
{ url = "https://files.pythonhosted.org/packages/90/15/021be5c0cbc5b7c1ab46e91cc3434eb42569f79a0592e67b8d25e66d844d/psycopg_binary-3.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6698dbab5bcef8fdb570fc9d35fd9ac52041771bfcfe6fd0fc5f5c4e36f1e99d", size = 4591170, upload-time = "2026-02-18T16:48:55.594Z" },
{ url = "https://files.pythonhosted.org/packages/f1/54/a60211c346c9a2f8c6b272b5f2bbe21f6e11800ce7f61e99ba75cf8b63e1/psycopg_binary-3.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:329ff393441e75f10b673ae99ab45276887993d49e65f141da20d915c05aafd8", size = 4670009, upload-time = "2026-02-18T16:49:03.608Z" },
{ url = "https://files.pythonhosted.org/packages/c1/53/ac7c18671347c553362aadbf65f92786eef9540676ca24114cc02f5be405/psycopg_binary-3.3.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:eb072949b8ebf4082ae24289a2b0fd724da9adc8f22743409d6fd718ddb379df", size = 5469735, upload-time = "2026-02-18T16:49:10.128Z" },
{ url = "https://files.pythonhosted.org/packages/7f/c3/4f4e040902b82a344eff1c736cde2f2720f127fe939c7e7565706f96dd44/psycopg_binary-3.3.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:263a24f39f26e19ed7fc982d7859a36f17841b05bebad3eb47bb9cd2dd785351", size = 5152919, upload-time = "2026-02-18T16:49:16.335Z" },
{ url = "https://files.pythonhosted.org/packages/0c/e7/d929679c6a5c212bcf738806c7c89f5b3d0919f2e1685a0e08d6ff877945/psycopg_binary-3.3.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5152d50798c2fa5bd9b68ec68eb68a1b71b95126c1d70adaa1a08cd5eefdc23d", size = 6738785, upload-time = "2026-02-18T16:49:22.687Z" },
{ url = "https://files.pythonhosted.org/packages/69/b0/09703aeb69a9443d232d7b5318d58742e8ca51ff79f90ffe6b88f1db45e7/psycopg_binary-3.3.3-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d6a1e56dd267848edb824dbeb08cf5bac649e02ee0b03ba883ba3f4f0bd54f2", size = 4979008, upload-time = "2026-02-18T16:49:27.313Z" },
{ url = "https://files.pythonhosted.org/packages/cc/a6/e662558b793c6e13a7473b970fee327d635270e41eded3090ef14045a6a5/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73eaaf4bb04709f545606c1db2f65f4000e8a04cdbf3e00d165a23004692093e", size = 4508255, upload-time = "2026-02-18T16:49:31.575Z" },
{ url = "https://files.pythonhosted.org/packages/5f/7f/0f8b2e1d5e0093921b6f324a948a5c740c1447fbb45e97acaf50241d0f39/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:162e5675efb4704192411eaf8e00d07f7960b679cd3306e7efb120bb8d9456cc", size = 4189166, upload-time = "2026-02-18T16:49:35.801Z" },
{ url = "https://files.pythonhosted.org/packages/92/ec/ce2e91c33bc8d10b00c87e2f6b0fb570641a6a60042d6a9ae35658a3a797/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:fab6b5e37715885c69f5d091f6ff229be71e235f272ebaa35158d5a46fd548a0", size = 3924544, upload-time = "2026-02-18T16:49:41.129Z" },
{ url = "https://files.pythonhosted.org/packages/c5/2f/7718141485f73a924205af60041c392938852aa447a94c8cbd222ff389a1/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a4aab31bd6d1057f287c96c0effca3a25584eb9cc702f282ecb96ded7814e830", size = 4235297, upload-time = "2026-02-18T16:49:46.726Z" },
{ url = "https://files.pythonhosted.org/packages/57/f9/1add717e2643a003bbde31b1b220172e64fbc0cb09f06429820c9173f7fc/psycopg_binary-3.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:59aa31fe11a0e1d1bcc2ce37ed35fe2ac84cd65bb9036d049b1a1c39064d0f14", size = 3547659, upload-time = "2026-02-18T16:49:52.999Z" },
{ url = "https://files.pythonhosted.org/packages/03/0a/cac9fdf1df16a269ba0e5f0f06cac61f826c94cadb39df028cdfe19d3a33/psycopg_binary-3.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05f32239aec25c5fb15f7948cffdc2dc0dac098e48b80a140e4ba32b572a2e7d", size = 4590414, upload-time = "2026-02-18T16:50:01.441Z" },
{ url = "https://files.pythonhosted.org/packages/9c/c0/d8f8508fbf440edbc0099b1abff33003cd80c9e66eb3a1e78834e3fb4fb9/psycopg_binary-3.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c84f9d214f2d1de2fafebc17fa68ac3f6561a59e291553dfc45ad299f4898c1", size = 4669021, upload-time = "2026-02-18T16:50:08.803Z" },
{ url = "https://files.pythonhosted.org/packages/04/05/097016b77e343b4568feddf12c72171fc513acef9a4214d21b9478569068/psycopg_binary-3.3.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e77957d2ba17cada11be09a5066d93026cdb61ada7c8893101d7fe1c6e1f3925", size = 5467453, upload-time = "2026-02-18T16:50:14.985Z" },
{ url = "https://files.pythonhosted.org/packages/91/23/73244e5feb55b5ca109cede6e97f32ef45189f0fdac4c80d75c99862729d/psycopg_binary-3.3.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:42961609ac07c232a427da7c87a468d3c82fee6762c220f38e37cfdacb2b178d", size = 5151135, upload-time = "2026-02-18T16:50:24.82Z" },
{ url = "https://files.pythonhosted.org/packages/11/49/5309473b9803b207682095201d8708bbc7842ddf3f192488a69204e36455/psycopg_binary-3.3.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae07a3114313dd91fce686cab2f4c44af094398519af0e0f854bc707e1aeedf1", size = 6737315, upload-time = "2026-02-18T16:50:35.106Z" },
{ url = "https://files.pythonhosted.org/packages/d4/5d/03abe74ef34d460b33c4d9662bf6ec1dd38888324323c1a1752133c10377/psycopg_binary-3.3.3-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d257c58d7b36a621dcce1d01476ad8b60f12d80eb1406aee4cf796f88b2ae482", size = 4979783, upload-time = "2026-02-18T16:50:42.067Z" },
{ url = "https://files.pythonhosted.org/packages/f0/6c/3fbf8e604e15f2f3752900434046c00c90bb8764305a1b81112bff30ba24/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:07c7211f9327d522c9c47560cae00a4ecf6687f4e02d779d035dd3177b41cb12", size = 4509023, upload-time = "2026-02-18T16:50:50.116Z" },
{ url = "https://files.pythonhosted.org/packages/9c/6b/1a06b43b7c7af756c80b67eac8bfaa51d77e68635a8a8d246e4f0bb7604a/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:8e7e9eca9b363dbedeceeadd8be97149d2499081f3c52d141d7cd1f395a91f83", size = 4185874, upload-time = "2026-02-18T16:50:55.97Z" },
{ url = "https://files.pythonhosted.org/packages/2b/d3/bf49e3dcaadba510170c8d111e5e69e5ae3f981c1554c5bb71c75ce354bb/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:cb85b1d5702877c16f28d7b92ba030c1f49ebcc9b87d03d8c10bf45a2f1c7508", size = 3925668, upload-time = "2026-02-18T16:51:03.299Z" },
{ url = "https://files.pythonhosted.org/packages/f8/92/0aac830ed6a944fe334404e1687a074e4215630725753f0e3e9a9a595b62/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4d4606c84d04b80f9138d72f1e28c6c02dc5ae0c7b8f3f8aaf89c681ce1cd1b1", size = 4234973, upload-time = "2026-02-18T16:51:09.097Z" },
{ url = "https://files.pythonhosted.org/packages/2e/96/102244653ee5a143ece5afe33f00f52fe64e389dfce8dbc87580c6d70d3d/psycopg_binary-3.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:74eae563166ebf74e8d950ff359be037b85723d99ca83f57d9b244a871d6c13b", size = 3551342, upload-time = "2026-02-18T16:51:13.892Z" },
{ url = "https://files.pythonhosted.org/packages/a2/71/7a57e5b12275fe7e7d84d54113f0226080423a869118419c9106c083a21c/psycopg_binary-3.3.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:497852c5eaf1f0c2d88ab74a64a8097c099deac0c71de1cbcf18659a8a04a4b2", size = 4607368, upload-time = "2026-02-18T16:51:19.295Z" },
{ url = "https://files.pythonhosted.org/packages/c7/04/cb834f120f2b2c10d4003515ef9ca9d688115b9431735e3936ae48549af8/psycopg_binary-3.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:258d1ea53464d29768bf25930f43291949f4c7becc706f6e220c515a63a24edd", size = 4687047, upload-time = "2026-02-18T16:51:23.84Z" },
{ url = "https://files.pythonhosted.org/packages/40/e9/47a69692d3da9704468041aa5ed3ad6fc7f6bb1a5ae788d261a26bbca6c7/psycopg_binary-3.3.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:111c59897a452196116db12e7f608da472fbff000693a21040e35fc978b23430", size = 5487096, upload-time = "2026-02-18T16:51:29.645Z" },
{ url = "https://files.pythonhosted.org/packages/0b/b6/0e0dd6a2f802864a4ae3dbadf4ec620f05e3904c7842b326aafc43e5f464/psycopg_binary-3.3.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:17bb6600e2455993946385249a3c3d0af52cd70c1c1cdbf712e9d696d0b0bf1b", size = 5168720, upload-time = "2026-02-18T16:51:36.499Z" },
{ url = "https://files.pythonhosted.org/packages/6f/0d/977af38ac19a6b55d22dff508bd743fd7c1901e1b73657e7937c7cccb0a3/psycopg_binary-3.3.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:642050398583d61c9856210568eb09a8e4f2fe8224bf3be21b67a370e677eead", size = 6762076, upload-time = "2026-02-18T16:51:43.167Z" },
{ url = "https://files.pythonhosted.org/packages/34/40/912a39d48322cf86895c0eaf2d5b95cb899402443faefd4b09abbba6b6e1/psycopg_binary-3.3.3-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:533efe6dc3a7cba5e2a84e38970786bb966306863e45f3db152007e9f48638a6", size = 4997623, upload-time = "2026-02-18T16:51:47.707Z" },
{ url = "https://files.pythonhosted.org/packages/98/0c/c14d0e259c65dc7be854d926993f151077887391d5a081118907a9d89603/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5958dbf28b77ce2033482f6cb9ef04d43f5d8f4b7636e6963d5626f000efb23e", size = 4532096, upload-time = "2026-02-18T16:51:51.421Z" },
{ url = "https://files.pythonhosted.org/packages/39/21/8b7c50a194cfca6ea0fd4d1f276158307785775426e90700ab2eba5cd623/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a6af77b6626ce92b5817bf294b4d45ec1a6161dba80fc2d82cdffdd6814fd023", size = 4208884, upload-time = "2026-02-18T16:51:57.336Z" },
{ url = "https://files.pythonhosted.org/packages/c7/2c/a4981bf42cf30ebba0424971d7ce70a222ae9b82594c42fc3f2105d7b525/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:47f06fcbe8542b4d96d7392c476a74ada521c5aebdb41c3c0155f6595fc14c8d", size = 3944542, upload-time = "2026-02-18T16:52:04.266Z" },
{ url = "https://files.pythonhosted.org/packages/60/e9/b7c29b56aa0b85a4e0c4d89db691c1ceef08f46a356369144430c155a2f5/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e7800e6c6b5dc4b0ca7cc7370f770f53ac83886b76afda0848065a674231e856", size = 4254339, upload-time = "2026-02-18T16:52:10.444Z" },
{ url = "https://files.pythonhosted.org/packages/98/5a/291d89f44d3820fffb7a04ebc8f3ef5dda4f542f44a5daea0c55a84abf45/psycopg_binary-3.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:165f22ab5a9513a3d7425ffb7fcc7955ed8ccaeef6d37e369d6cc1dff1582383", size = 3652796, upload-time = "2026-02-18T16:52:14.02Z" },
{ url = "https://files.pythonhosted.org/packages/b6/82/df3312c0ca083d5b43b352f27d4dd8b1e614bd334473074715d9e0000da4/psycopg_binary-3.3.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:612a627d733f695b1de1f9b4bd511c15f999a5d8b915d444bbd7dd71cf3370da", size = 4609813, upload-time = "2026-05-01T23:26:30.612Z" },
{ url = "https://files.pythonhosted.org/packages/1f/b5/d74d542458d3e8ac0571d8a88f57ca369999b9a82f4fa528052d0d7d3e4c/psycopg_binary-3.3.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:13a7f380824c35896dcac7fe0f61440f7ca49d6dc73f3c13a9a4471e6a3b302e", size = 4676799, upload-time = "2026-05-01T23:26:38.475Z" },
{ url = "https://files.pythonhosted.org/packages/09/67/06bab9c60671999f4c6ceff1b334f3ac1f9fc5789eb467c714623ea21de9/psycopg_binary-3.3.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:276904e3452d6a23d474ef9a21eee19f20eed3d53ddd2576af033827e0ba0992", size = 5497050, upload-time = "2026-05-01T23:26:47.061Z" },
{ url = "https://files.pythonhosted.org/packages/72/9b/023433e2b20f970de1e22d29132a95281277646da0b2e2879dd4ee94b8c1/psycopg_binary-3.3.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab8cca8ef8fb1ccf5b048ae5bd78ba55b9e4b5d472e3ce5ca39ff4d2a9c249e4", size = 5172428, upload-time = "2026-05-01T23:26:56.708Z" },
{ url = "https://files.pythonhosted.org/packages/08/cd/ae16da8fde228a38b2fe9269bbc13cf89e0186173f2265600f02d6a71e64/psycopg_binary-3.3.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7465bfe6087d2d5b42d4c53b9b11ca9f218e477317a4a162a10e3c19e984ba8e", size = 6762746, upload-time = "2026-05-01T23:27:07.023Z" },
{ url = "https://files.pythonhosted.org/packages/4f/81/0ba09fa5f5f88779093a2541a8e02489825721f258ab88058b11d68b3eb5/psycopg_binary-3.3.4-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22cdbf5f91ef7bb91fe0c5757e1962d3127a8010256eefd9c61fcaf441802097", size = 5006033, upload-time = "2026-05-01T23:27:12.221Z" },
{ url = "https://files.pythonhosted.org/packages/73/6a/629136040cc3497adb442a305710b5913f2a754d4630fc3d3717c4c0df65/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e2631da29253a98bd496e6c4813b24e09a4fe3fb2a9e88513305d6f8747cce95", size = 4534175, upload-time = "2026-05-01T23:27:18.248Z" },
{ url = "https://files.pythonhosted.org/packages/7c/32/1027f843c6dc2d5d51960ee62cc0c2cf755a4c39455aff1371173edbef7d/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7f7668f30b9dd5163197e5cbf4e0efd54e00f0a859cc566ce56cfc31f4054839", size = 4224203, upload-time = "2026-05-01T23:27:24.3Z" },
{ url = "https://files.pythonhosted.org/packages/0b/e1/380a724d9093c74adb14d4fce920ea8327838abb61f760b1448586b14a8e/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:cffc3408d77a27973f33e5d909b624cce683db5fc25964b02fe0aae7886c1007", size = 3954509, upload-time = "2026-05-01T23:27:30.815Z" },
{ url = "https://files.pythonhosted.org/packages/db/cd/895893ae575a09c97ccfd5def070d88993d955ef34df45a881fd5ff506d6/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0579252a1202cd73e4da137a1426e2dae993ae44e757605344282af3a082848c", size = 4259551, upload-time = "2026-05-01T23:27:38.828Z" },
{ url = "https://files.pythonhosted.org/packages/dd/c6/2330a20794e37a3ec609ef2fd8522919ec7a4395a1abf979a8e2d1775cd5/psycopg_binary-3.3.4-cp311-cp311-win_amd64.whl", hash = "sha256:41f2ec0fea529832982bcb6c9415de3c86264ebe562b77a467c0fbcd7efbba8d", size = 3572054, upload-time = "2026-05-01T23:27:45.455Z" },
{ url = "https://files.pythonhosted.org/packages/95/7d/03818e13ba7f36de93573c93ee3482006d3dfa8b0f8d28df511bad0a1a92/psycopg_binary-3.3.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5ab28a2a7649df3b72e6b674b4c190e448e8e77cf496a65bd846472048de2089", size = 4591122, upload-time = "2026-05-01T23:27:56.162Z" },
{ url = "https://files.pythonhosted.org/packages/a5/b9/11b341edf8d54e2694726b273fe9652b254d989f4f63e3ac6816ad6b55f4/psycopg_binary-3.3.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6402a9d8146cf4b3974ded3fd28a971e83dc6a0333eb7822524a3aa20b546578", size = 4669943, upload-time = "2026-05-01T23:28:04.522Z" },
{ url = "https://files.pythonhosted.org/packages/8b/18/4665bacd65e7865b4372fcd8abb8b9186ada4b0025f8c2ca691b364a556c/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:580ae30a5f95ccd90008ec697d3ed6a4a2047a516407ad904283fa42086936e9", size = 5469697, upload-time = "2026-05-01T23:28:11.337Z" },
{ url = "https://files.pythonhosted.org/packages/7c/b1/b83136c6e510593d9b0c759ba5384337bc4ad82d19fda675adc4b2703c84/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7510c37550f91a187e3660a8cc50d4b760f8c3b8b2f89ebc5698cd2c7f2c85d", size = 5152995, upload-time = "2026-05-01T23:28:20.529Z" },
{ url = "https://files.pythonhosted.org/packages/67/8d/a9821e2a648afe6091989929982a3b0f00b2631a859cb81379728f08fb75/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77df19583501ea288eaf15ac0fe7ad01e6d8091a91d5c41df5c718f307d8e31b", size = 6738180, upload-time = "2026-05-01T23:28:30.654Z" },
{ url = "https://files.pythonhosted.org/packages/7e/58/2e349e8d23905dc2317b80ac65f48fb6f821a4777a4e994a60da91c4850f/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:018fbed325936da502feb546642c982dcc4b9ffdea32dfef78dbf3b7f7ad4070", size = 4978828, upload-time = "2026-05-01T23:28:37.277Z" },
{ url = "https://files.pythonhosted.org/packages/45/48/57b00d03b4721878326122a1f1e6b0a90b85bcaec56b5b2f8ea6cfa45235/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:17a21953a9e5ff3a16dab692625a3676e2f101db5e40072f39dbee2250194d68", size = 4509757, upload-time = "2026-05-01T23:28:43.078Z" },
{ url = "https://files.pythonhosted.org/packages/25/37/33b47d8c007df69aec500df5889767c4d313748e8e9e27a2fef8a6dabcee/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:eb05ee1c2b817d27c537333224c9e83c7afb86fe7296ba970990068baf819b16", size = 4190546, upload-time = "2026-05-01T23:28:50.016Z" },
{ url = "https://files.pythonhosted.org/packages/ca/c6/32b0835dbc2122617902b649d76a91c1e75406e76bf3d595b0c3bb5ffad6/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:773d573e11f437ce0bdb95b7c18dc58390494f96d43f8b45b9760436114f7652", size = 3926197, upload-time = "2026-05-01T23:28:55.55Z" },
{ url = "https://files.pythonhosted.org/packages/cd/68/d190ef0c0c5b16ded07831dabc8ddd412f4cdab07ec6e30ed38d9bda0e1f/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e55ccbdfae79a2ed9c6369c3008a3025817ff9d7e27b32a2d84e2a4267e66e", size = 4236627, upload-time = "2026-05-01T23:29:05.336Z" },
{ url = "https://files.pythonhosted.org/packages/25/8f/81dcbc2e8454b74d14881275ea45f00791052dac531a9fa8be1730d1685b/psycopg_binary-3.3.4-cp312-cp312-win_amd64.whl", hash = "sha256:494ca54901be8cf9eb7e02c25b731f2317c378efa44f43e8f9bd0e1184ae7be4", size = 3560782, upload-time = "2026-05-01T23:29:11.967Z" },
{ url = "https://files.pythonhosted.org/packages/09/43/13e9c406fbbf354580476e248a16b64802a376873ebe6339e30bb655572d/psycopg_binary-3.3.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fbd1d4ed566895ad2d3bf4ddfd8bae90026930ddf29df3b9d91d32c8c47866a7", size = 4590377, upload-time = "2026-05-01T23:29:18.782Z" },
{ url = "https://files.pythonhosted.org/packages/22/be/2923cd7c3683e7afdecf4f10796a18de02f5c5ddc0969aa2ad0a8cdd3bbd/psycopg_binary-3.3.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:75a9067e236f9b9ae3535b66fe99bddb33d39c0de10112e49b9ab11eee53dc31", size = 4669023, upload-time = "2026-05-01T23:29:25.884Z" },
{ url = "https://files.pythonhosted.org/packages/96/a0/2c913d6fe13d6a8bd13597d36739bf47af063ad9399e402cfecab16f3c1e/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b56b603ebcea8aa10b46228b8410ba7f13e7c2ee54389d4d9be0927fd8ce2a70", size = 5467423, upload-time = "2026-05-01T23:29:33.416Z" },
{ url = "https://files.pythonhosted.org/packages/e7/38/205d10bc1ad0df4a21c5c51659126bd3ea0ef98fcad1e852f78c249bb9c3/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c677c4ad433cb7150c8cd304a0769ae3bcfbe5ea0676eb53faa7b1443b16d0d3", size = 5151137, upload-time = "2026-05-01T23:29:42.013Z" },
{ url = "https://files.pythonhosted.org/packages/36/fc/f0381ddcd45eff3bb70dbca6823a996048d7f507b2ec3fc92c6fabc0fe87/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26df2717e59c0473e4465a97dfb1b7afebaa479277870fd5784d1436470db47c", size = 6736671, upload-time = "2026-05-01T23:29:51.626Z" },
{ url = "https://files.pythonhosted.org/packages/95/40/fa545ae152c24327651e5624e4902121e808270be36c10b12e9939be09bc/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dc1f79fd16bb1f3f4421417a514607539f17804d95c7ed617265369d1981cae", size = 4979601, upload-time = "2026-05-01T23:29:56.961Z" },
{ url = "https://files.pythonhosted.org/packages/86/e4/2f8a47ee97f90cd2b933d0463081d35631ff419de2b8c984a5f369857de0/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:136f199a407b5348b9b857c504aff60c77622a28482e7195839ce1b51238c4cc", size = 4510513, upload-time = "2026-05-01T23:30:07.243Z" },
{ url = "https://files.pythonhosted.org/packages/0e/0e/94e842ff4a7f98ed162580ca2e8b8864b28c1e0350f2443f8ee47f821167/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b6f5a29e9c775b9f12a1a717aa7a2c80f9e1db6f27ba44a5b59c80ac61d2ffcf", size = 4187243, upload-time = "2026-05-01T23:30:15.352Z" },
{ url = "https://files.pythonhosted.org/packages/d0/83/fc6c174b672e29b7de996ea77b6cbddf46c891751c3355f6974292baa6b4/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ee17a2cf4943cde261adfad1bbc5bf38d6b3776d7afff74c7cabcbeaeb08c260", size = 3927347, upload-time = "2026-05-01T23:30:21.186Z" },
{ url = "https://files.pythonhosted.org/packages/e9/65/768364d4a97a15b1a7f47ba52688c1686f22941d8332a8398cefc468e25f/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c4ab71be17bdca30cb34c34c4e1496e2f5d6f20c199c12bad226070b22ef9bf", size = 4236393, upload-time = "2026-05-01T23:30:26.211Z" },
{ url = "https://files.pythonhosted.org/packages/bd/3b/218efbc9e645becd80cdf651acda05f85cfe546b7a9c0458c7cbc8fe1f74/psycopg_binary-3.3.4-cp313-cp313-win_amd64.whl", hash = "sha256:dbfdb9b6cc79f31104a7b162a2b921b765fcc62af6c00540a167a8de47e4ed38", size = 3564592, upload-time = "2026-05-01T23:30:31.764Z" },
{ url = "https://files.pythonhosted.org/packages/48/a6/828c9185701dab71b234c2a76c38a08b098ebfec5020716b4e93807492b5/psycopg_binary-3.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:28b7398fdd19db3232c884fb24550bdfe951221f510e195e233299e4c9b78f97", size = 4607292, upload-time = "2026-05-01T23:30:38.962Z" },
{ url = "https://files.pythonhosted.org/packages/92/58/5b40dbc9d839045c9dae956960e4fb6d20bcabe6c59a2aa34fc3a371913f/psycopg_binary-3.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1fbaa292a3c8bb61b45df1ad3da1908ccee7cb889db9425e3557d9e34e2a4829", size = 4687023, upload-time = "2026-05-01T23:30:47.227Z" },
{ url = "https://files.pythonhosted.org/packages/85/a9/793f0ac107a9003b48441d0d1f9f616d96e0f37458dd8dc12528ceff55fb/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94596f9e7633ee3f6440711d43bb70aa31cc0a46a900ab8b4201a366ace5c9e7", size = 5486985, upload-time = "2026-05-01T23:30:55.517Z" },
{ url = "https://files.pythonhosted.org/packages/8f/26/42e8533497e2592334f68ec529cf5f840f7fa4e99575a4bb61aa184dbfbf/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8c0056529e68dbe9184cd4019a1f3d8f3a4ead2f6fc7a5afcf27d3314edd1277", size = 5168745, upload-time = "2026-05-01T23:31:01.904Z" },
{ url = "https://files.pythonhosted.org/packages/15/af/b7151776cc08d5935d45c833ec818a9beb417cf7c08239af1aafbdae78ee/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c09aad7051326e7603c14e50636db9c01f78272dc54b3accff03d46370461e6", size = 6761486, upload-time = "2026-05-01T23:31:14.511Z" },
{ url = "https://files.pythonhosted.org/packages/d0/ed/c92533b9124712d592cbf1cd6c76da933a2e0acea81dfe1fbe7e735f0cff/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:514404ed543efd620c85602b747df2a23cf1241b4067199e1a66f2d2757aaa41", size = 4997427, upload-time = "2026-05-01T23:31:20.901Z" },
{ url = "https://files.pythonhosted.org/packages/a2/23/ccadfd0de416aa188356daa199453af24087b042e296088706d190ae0295/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:46893c26858be12cc49ca4226ed6a60b4bfccadd946b3bebb783a60b38788228", size = 4533549, upload-time = "2026-05-01T23:31:26.204Z" },
{ url = "https://files.pythonhosted.org/packages/fd/a0/c8f43cee36386f7bc891ab41a9d31ea07cf9826038e732da79f26b1e5f34/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:df1d567fc430f6df15c9fcf67d87685fc49bdb325adc0db5af1adfb2f44eb5c9", size = 4210256, upload-time = "2026-05-01T23:31:33.884Z" },
{ url = "https://files.pythonhosted.org/packages/4e/2c/c1547871be3790676e8868b38655496422f94f0978dfb66b74bdba2f1676/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6b9016b1714da4dd5ecaaa75b82098aa5a0b87854ce9b092e21c27c4ae23e014", size = 3946204, upload-time = "2026-05-01T23:31:39.626Z" },
{ url = "https://files.pythonhosted.org/packages/c4/b1/f6670f00fa7ea601584623f6c11602ab92117d83eaff885e0210f6de7418/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:47c656a8a7ba6eb0cff1801a4caaa9c8bdc12d03080e273aff1c8ac39971a77e", size = 4255811, upload-time = "2026-05-01T23:31:44.986Z" },
{ url = "https://files.pythonhosted.org/packages/eb/e6/5fff07a70d1f945ed90ae131c3bd76cab32beff7c58c6db15ad5820b6d1f/psycopg_binary-3.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8", size = 3666849, upload-time = "2026-05-01T23:31:51.165Z" },
]
[[package]]
@@ -2027,11 +2027,11 @@ wheels = [
[[package]]
name = "python-multipart"
version = "0.0.26"
version = "0.0.27"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/88/71/b145a380824a960ebd60e1014256dbb7d2253f2316ff2d73dfd8928ec2c3/python_multipart-0.0.26.tar.gz", hash = "sha256:08fadc45918cd615e26846437f50c5d6d23304da32c341f289a617127b081f17", size = 43501, upload-time = "2026-04-10T14:09:59.473Z" }
sdist = { url = "https://files.pythonhosted.org/packages/69/9b/f23807317a113dc36e74e75eb265a02dd1a4d9082abc3c1064acd22997c4/python_multipart-0.0.27.tar.gz", hash = "sha256:9870a6a8c5a20a5bf4f07c017bd1489006ff8836cff097b6933355ee2b49b602", size = 44043, upload-time = "2026-04-27T10:51:26.649Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/22/f1925cdda983ab66fc8ec6ec8014b959262747e58bdca26a4e3d1da29d56/python_multipart-0.0.26-py3-none-any.whl", hash = "sha256:c0b169f8c4484c13b0dcf2ef0ec3a4adb255c4b7d18d8e420477d2b1dd03f185", size = 28847, upload-time = "2026-04-10T14:09:58.131Z" },
{ url = "https://files.pythonhosted.org/packages/99/78/4126abcbdbd3c559d43e0db7f7b9173fc6befe45d39a2856cc0b8ec2a5a6/python_multipart-0.0.27-py3-none-any.whl", hash = "sha256:6fccfad17a27334bd0193681b369f476eda3409f17381a2d65aa7df3f7275645", size = 29254, upload-time = "2026-04-27T10:51:24.997Z" },
]
[[package]]
@@ -2533,7 +2533,7 @@ wheels = [
[[package]]
name = "turnstone"
version = "1.5.6"
version = "1.5.7"
source = { editable = "." }
dependencies = [
{ name = "alembic" },