mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-13 15:32:24 -06:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 273d547f4e | |||
| bbb404c363 | |||
| 072113f7ca | |||
| 7f1b0acf7a | |||
| 6904bd8f39 | |||
| 4d677d1ebf |
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
Generated
+12
-12
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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
@@ -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">←</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,
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user