tina4-python 3.13.131__tar.gz → 3.13.133__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {tina4_python-3.13.131 → tina4_python-3.13.133}/PKG-INFO +1 -1
- {tina4_python-3.13.131 → tina4_python-3.13.133}/pyproject.toml +1 -1
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/__init__.py +1 -1
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/cli/__init__.py +4 -9
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/core/server.py +70 -1
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/database/adapter.py +24 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/database/firebird.py +38 -23
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/database/mssql.py +0 -17
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/database/mysql.py +0 -17
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/dev_admin/__init__.py +14 -242
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/frond/compiler.py +6 -30
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/frond/engine.py +4 -3
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/orm/__init__.py +2 -0
- tina4_python-3.13.133/tina4_python/orm/collection.py +82 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/orm/model.py +25 -31
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/public/js/tina4-dev-admin.min.js +148 -512
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/session_handlers/redis_handler.py +19 -10
- tina4_python-3.13.133/tina4_python/session_handlers/valkey_handler.py +28 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/swagger/__init__.py +227 -175
- tina4_python-3.13.131/tina4_python/session_handlers/valkey_handler.py +0 -211
- {tina4_python-3.13.131 → tina4_python-3.13.133}/.gitignore +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/README.md +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/CLAUDE.md +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/HtmlElement.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/Testing.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/_import_helper.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/ai/__init__.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/ai/client.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/api/__init__.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/auth/__init__.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/cache/__init__.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/container/__init__.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/context/__init__.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/context/chunker.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/core/__init__.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/core/cache.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/core/constants.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/core/events.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/core/middleware.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/core/port_takeover.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/core/rate_limiter.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/core/request.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/core/response.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/core/router.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/crud/__init__.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/database/__init__.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/database/connection.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/database/database_url.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/database/mongodb.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/database/odbc.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/database/postgres.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/database/sql_translator.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/database/sqlite.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/debug/__init__.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/debug/error_overlay.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/dev_admin/metrics.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/dev_admin/plan.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/dev_admin/project_index.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/docs.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/docstore/__init__.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/dotenv/__init__.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/env.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/frond/FROND.md +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/frond/__init__.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/frond/parser.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/gallery/auth/meta.json +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/gallery/database/meta.json +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/gallery/error-overlay/meta.json +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/gallery/orm/meta.json +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/gallery/queue/meta.json +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/gallery/rest-api/meta.json +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/gallery/templates/meta.json +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/graph/__init__.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/graph/adapter.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/graph/adapters/__init__.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/graph/adapters/arango.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/graph/adapters/bolt.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/graph/adapters/ultipa.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/graph/graph_url.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/graphql/__init__.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/i18n/__init__.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/mcp/__init__.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/mcp/protocol.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/mcp/tools.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/messenger/__init__.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/migration/__init__.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/migration/runner.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/mqtt/__init__.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/mqtt/message.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/orm/fields.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/orm/point.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/public/__feedback/widget.js +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/public/css/tina4.css +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/public/css/tina4.min.css +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/public/favicon.ico +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/public/images/logo.svg +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/public/js/frond.js +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/public/js/frond.min.js +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/public/js/tina4.min.js +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/public/js/tina4js.min.js +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/public/swagger/index.html +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/query_builder/__init__.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/queue/__init__.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/queue/amqp_url.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/queue/job.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/queue/kafka_backend.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/queue/lite_backend.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/queue/mongo_backend.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/queue/rabbitmq_backend.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/queue_backends/__init__.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/queue_backends/kafka_backend.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/queue_backends/mongo_backend.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/realtime/__init__.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/realtime/models/Attachment.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/realtime/models/Channel.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/realtime/models/ChannelMember.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/realtime/models/Message.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/realtime/models/Workspace.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/realtime/models/__init__.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/realtime/storage.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/seeder/__init__.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/service/__init__.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/session/__init__.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/session_handlers/__init__.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/session_handlers/memcached_handler.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/session_handlers/mongodb_handler.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/sso.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/templates/components/crud.twig +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/templates/docker/python/Dockerfile +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/templates/docker/uv/Dockerfile +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/templates/errors/302.twig +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/templates/errors/401.twig +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/templates/errors/403.twig +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/templates/errors/404.twig +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/templates/errors/500.twig +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/templates/errors/502.twig +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/templates/errors/503.twig +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/templates/errors/base.twig +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/templates/frontend/README.md +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/templates/readme.md +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/test/__init__.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/test_client/__init__.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/validator/__init__.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/websocket/__init__.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/websocket/backplane.py +0 -0
- {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/wsdl/__init__.py +0 -0
|
@@ -1795,15 +1795,10 @@ async def list_{route_path}(request, response):
|
|
|
1795
1795
|
page = int(request.query.get("page", 1))
|
|
1796
1796
|
per_page = int(request.query.get("per_page", 20))
|
|
1797
1797
|
offset = (page - 1) * per_page
|
|
1798
|
-
records
|
|
1798
|
+
records = {model}.where("1=1", limit=per_page, offset=offset)
|
|
1799
1799
|
# tina4:edit customise the list projection (filters, ordering, fields)
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
"count": total,
|
|
1803
|
-
"page": page,
|
|
1804
|
-
"per_page": per_page,
|
|
1805
|
-
"total_pages": max(1, -(-total // per_page)),
|
|
1806
|
-
}})
|
|
1800
|
+
# records.to_paginate() -> records, total, page, per_page, total_pages, limit, offset
|
|
1801
|
+
return response(records.to_paginate())
|
|
1807
1802
|
|
|
1808
1803
|
|
|
1809
1804
|
@description("Get a {singular} by ID")
|
|
@@ -1862,7 +1857,7 @@ async def delete_{singular}(request, response):
|
|
|
1862
1857
|
b_list = _ai_fill(
|
|
1863
1858
|
f"list_{route_path}",
|
|
1864
1859
|
f"return the {route_path} collection (add pagination if it grows)",
|
|
1865
|
-
f"{m}.all() or {m}.where(sql, params
|
|
1860
|
+
f"{m}.all() or {m}.where(sql, params).to_paginate() (import {m} from src.orm.{m})",
|
|
1866
1861
|
f"list_{route_path}: query and return the records",
|
|
1867
1862
|
ret='response({"records": [r.to_dict() for r in rows]})',
|
|
1868
1863
|
ground=f'tina4_context("list ORM records with pagination", "python")',
|
|
@@ -3462,6 +3462,64 @@ def resolve_config(cli_host: str | None = None, cli_port: int | None = None) ->
|
|
|
3462
3462
|
return host, port
|
|
3463
3463
|
|
|
3464
3464
|
|
|
3465
|
+
def loopback_bind_hosts(host: str) -> list[str]:
|
|
3466
|
+
"""Sibling loopback addresses to ALSO listen on, so ``localhost`` reaches
|
|
3467
|
+
the dev server whether the OS resolves it to IPv4 (127.0.0.1) or IPv6 (::1).
|
|
3468
|
+
|
|
3469
|
+
Windows resolves ``localhost`` to ``::1`` FIRST, so a server bound only to
|
|
3470
|
+
127.0.0.1 — or 0.0.0.0, the IPv4 wildcard, which does NOT cover IPv6 —
|
|
3471
|
+
refused the browser with ERR_CONNECTION_REFUSED even though it was serving.
|
|
3472
|
+
Binding both loopback families closes that gap.
|
|
3473
|
+
|
|
3474
|
+
Returns only the families a direct bind of ``host`` does not already cover.
|
|
3475
|
+
A host that is neither loopback nor a wildcard yields an empty list — an
|
|
3476
|
+
explicit LAN address is left exactly as asked. The host is normalised
|
|
3477
|
+
(whitespace and IPv6 brackets stripped, lower-cased) so ``" [::1] "`` maps
|
|
3478
|
+
the same as ``"::1"``. Addresses are returned WITHOUT brackets: Python's
|
|
3479
|
+
asyncio/socket layer takes a bare ``"::1"``.
|
|
3480
|
+
|
|
3481
|
+
Mirrors PHP ``Server::loopbackBindHosts`` (which returns bracketed forms
|
|
3482
|
+
because PHP's stream_socket_server needs them).
|
|
3483
|
+
|
|
3484
|
+
Args:
|
|
3485
|
+
host: The host the main socket binds.
|
|
3486
|
+
|
|
3487
|
+
Returns:
|
|
3488
|
+
Extra bind addresses (possibly empty).
|
|
3489
|
+
"""
|
|
3490
|
+
normalized = host.strip().strip("[]").lower()
|
|
3491
|
+
|
|
3492
|
+
return {
|
|
3493
|
+
"localhost": ["127.0.0.1", "::1"],
|
|
3494
|
+
"127.0.0.1": ["::1"],
|
|
3495
|
+
"0.0.0.0": ["::1"],
|
|
3496
|
+
"::1": ["127.0.0.1"],
|
|
3497
|
+
"::": ["127.0.0.1"],
|
|
3498
|
+
}.get(normalized, [])
|
|
3499
|
+
|
|
3500
|
+
|
|
3501
|
+
async def _bind_loopback_siblings(handler, host: str, port: int) -> list:
|
|
3502
|
+
"""Best-effort listeners on the sibling loopback family (see
|
|
3503
|
+
:func:`loopback_bind_hosts`), serving the SAME ``handler`` as the primary.
|
|
3504
|
+
|
|
3505
|
+
Each sibling bind is wrapped: a family the primary socket already answers
|
|
3506
|
+
(EADDRINUSE), or one this host cannot provide (EADDRNOTAVAIL / EAFNOSUPPORT),
|
|
3507
|
+
raises ``OSError`` and is skipped. A sibling failure NEVER fails startup —
|
|
3508
|
+
the primary bind owns that. Returns the asyncio servers that bound (possibly
|
|
3509
|
+
empty); the caller closes them alongside the primary.
|
|
3510
|
+
"""
|
|
3511
|
+
servers = []
|
|
3512
|
+
for sibling_host in loopback_bind_hosts(host):
|
|
3513
|
+
try:
|
|
3514
|
+
servers.append(await asyncio.start_server(handler, sibling_host, port))
|
|
3515
|
+
except OSError as sibling_error:
|
|
3516
|
+
Log.debug(
|
|
3517
|
+
f"Loopback sibling {sibling_host}:{port} not bound "
|
|
3518
|
+
f"({type(sibling_error).__name__}: {sibling_error}) — skipping"
|
|
3519
|
+
)
|
|
3520
|
+
return servers
|
|
3521
|
+
|
|
3522
|
+
|
|
3465
3523
|
_PORT_DEPRECATION_WARNED = False
|
|
3466
3524
|
|
|
3467
3525
|
|
|
@@ -4011,6 +4069,15 @@ def run(host: str | None = None, port: int | None = None, no_browser: bool = Fal
|
|
|
4011
4069
|
|
|
4012
4070
|
server = await start_server(_handle_connection, host, port)
|
|
4013
4071
|
|
|
4072
|
+
# ALSO listen on the sibling loopback family, best-effort, on the same
|
|
4073
|
+
# main port. `localhost` resolves to ::1 (IPv6) first on Windows, so a
|
|
4074
|
+
# server bound only to 127.0.0.1 (or the IPv4 wildcard 0.0.0.0) refused
|
|
4075
|
+
# the browser with ERR_CONNECTION_REFUSED. Each sibling bind is caught
|
|
4076
|
+
# and skipped on OSError — a family already answered by the primary, or
|
|
4077
|
+
# unavailable here, never fails startup. The primary bind above is
|
|
4078
|
+
# unchanged and owns the fail-closed behaviour.
|
|
4079
|
+
loopback_servers = await _bind_loopback_siblings(_handle_connection, host, port)
|
|
4080
|
+
|
|
4014
4081
|
# Test port (port + 1000) — stable, no live-reload WebSocket
|
|
4015
4082
|
ai_server = None
|
|
4016
4083
|
if _ai_port:
|
|
@@ -4041,7 +4108,9 @@ def run(host: str | None = None, port: int | None = None, no_browser: bool = Fal
|
|
|
4041
4108
|
|
|
4042
4109
|
# 1. Stop accepting FIRST. A connection that arrives after the signal
|
|
4043
4110
|
# must get a clean CONNECTION REFUSED — not a 503, not a TCP reset.
|
|
4044
|
-
live_servers = [
|
|
4111
|
+
live_servers = [
|
|
4112
|
+
s for s in (ai_server, server, *loopback_servers) if s is not None
|
|
4113
|
+
]
|
|
4045
4114
|
for live_server in live_servers:
|
|
4046
4115
|
live_server.close()
|
|
4047
4116
|
|
|
@@ -1172,6 +1172,30 @@ class SqlCrudMixin:
|
|
|
1172
1172
|
sql += f" WHERE {self._marked_filter(filter_sql)}"
|
|
1173
1173
|
return self.execute(sql, params or [])
|
|
1174
1174
|
|
|
1175
|
+
def _returning_pk(self, table: str) -> str | None:
|
|
1176
|
+
"""The table's single PRIMARY KEY column, for RETURNING emulation.
|
|
1177
|
+
|
|
1178
|
+
MySQL, MSSQL and Firebird have no usable native RETURNING here (Firebird
|
|
1179
|
+
has it from 2.1+ but this adapter emulates for cross-engine consistency),
|
|
1180
|
+
so after an INSERT they re-select the just-inserted row by its REAL
|
|
1181
|
+
primary key -- never a hardcoded ``id`` (the ``*-RETURNING-ID`` fixes: a
|
|
1182
|
+
table whose PK is not named ``id`` used to fail or re-select the wrong
|
|
1183
|
+
row). Introspected via :meth:`get_columns` and cached per table. Returns
|
|
1184
|
+
``None`` when the table has no single-column PK, so a composite or
|
|
1185
|
+
key-less table degrades to no re-select rather than a wrong one.
|
|
1186
|
+
|
|
1187
|
+
Shared by every SQL adapter that emulates RETURNING; engines with native
|
|
1188
|
+
RETURNING (PostgreSQL, SQLite) never call it.
|
|
1189
|
+
"""
|
|
1190
|
+
cache = self.__dict__.setdefault("_returning_pk_cache", {})
|
|
1191
|
+
if table not in cache:
|
|
1192
|
+
try:
|
|
1193
|
+
pks = [c["name"] for c in self.get_columns(table) if c.get("primary_key")]
|
|
1194
|
+
except Exception: # noqa: BLE001 - no introspection => no re-select
|
|
1195
|
+
pks = []
|
|
1196
|
+
cache[table] = pks[0] if len(pks) == 1 else None
|
|
1197
|
+
return cache[table]
|
|
1198
|
+
|
|
1175
1199
|
|
|
1176
1200
|
# ── SQL Translation Rules ──────────────────────────────────────
|
|
1177
1201
|
# Reusable translation functions for common cross-engine quirks.
|
|
@@ -280,6 +280,31 @@ class FirebirdAdapter(SqlCrudMixin, DatabaseAdapter):
|
|
|
280
280
|
self._conn.close()
|
|
281
281
|
self._conn = None
|
|
282
282
|
|
|
283
|
+
def __del__(self):
|
|
284
|
+
# Deterministic teardown when a caller (or a test) drops this adapter
|
|
285
|
+
# without calling close(). Without it, firebird-driver's OWN Connection
|
|
286
|
+
# finalizer later runs a rollback on a transaction whose handle is no
|
|
287
|
+
# longer valid -- "invalid transaction handle (expecting explicit
|
|
288
|
+
# transaction start)" -- which surfaces as a
|
|
289
|
+
# PytestUnraisableExceptionWarning (and GC noise in an app). Resolve any
|
|
290
|
+
# open transaction and detach HERE, so the driver's finalizer finds an
|
|
291
|
+
# already-closed connection and has nothing left to roll back
|
|
292
|
+
# (FB-DEL-CLOSE). __del__ can run during interpreter shutdown and on an
|
|
293
|
+
# already-dead connection, so swallow everything.
|
|
294
|
+
conn = getattr(self, "_conn", None)
|
|
295
|
+
if conn is None:
|
|
296
|
+
return
|
|
297
|
+
try:
|
|
298
|
+
if self._active_transaction() is not None:
|
|
299
|
+
conn.rollback()
|
|
300
|
+
except Exception: # noqa: BLE001 - stale/dead handle; nothing to undo
|
|
301
|
+
pass
|
|
302
|
+
try:
|
|
303
|
+
conn.close()
|
|
304
|
+
except Exception: # noqa: BLE001 - best-effort detach during GC
|
|
305
|
+
pass
|
|
306
|
+
self._conn = None
|
|
307
|
+
|
|
283
308
|
def execute(self, sql: str, params: list = None) -> DatabaseResult:
|
|
284
309
|
sql = self._translate_sql(sql)
|
|
285
310
|
|
|
@@ -297,7 +322,19 @@ class FirebirdAdapter(SqlCrudMixin, DatabaseAdapter):
|
|
|
297
322
|
# reflects the LAST statement executed on the cursor, so the generator
|
|
298
323
|
# re-select below (another execute on this same cursor) would otherwise
|
|
299
324
|
# clobber the INSERT/UPDATE/DELETE count (FB-AFFECTED-FAB).
|
|
300
|
-
|
|
325
|
+
#
|
|
326
|
+
# A DDL statement (CREATE/DROP/ALTER TABLE, etc.) has NO row count:
|
|
327
|
+
# firebird-driver's `rowcount` probes `get_info(RECORDS)` on the cursor,
|
|
328
|
+
# which the server answers with an error response for DDL, and the driver
|
|
329
|
+
# raises `InterfaceError: Result code does not match request code`. Reading
|
|
330
|
+
# rowcount unconditionally therefore blew up every DDL execute (create_table,
|
|
331
|
+
# migrations, the live-Firebird test fixtures) -- FB-DDL-ROWCOUNT. Guard it:
|
|
332
|
+
# DDL (and any statement without a countable result) yields 0 affected rows.
|
|
333
|
+
try:
|
|
334
|
+
_row_count = cursor.rowcount
|
|
335
|
+
affected = _row_count if _row_count >= 0 else 0
|
|
336
|
+
except Exception: # noqa: BLE001 - no rowcount on DDL; it is not a write
|
|
337
|
+
affected = 0
|
|
301
338
|
|
|
302
339
|
records = []
|
|
303
340
|
last_id = None
|
|
@@ -611,28 +648,6 @@ class FirebirdAdapter(SqlCrudMixin, DatabaseAdapter):
|
|
|
611
648
|
return token[1:-1].replace('""', '"')
|
|
612
649
|
return token
|
|
613
650
|
|
|
614
|
-
def _returning_pk(self, table: str) -> str | None:
|
|
615
|
-
"""The table's single PRIMARY KEY column, for the RETURNING re-select.
|
|
616
|
-
|
|
617
|
-
Firebird 2.1+ has native RETURNING but this adapter emulates it for
|
|
618
|
-
consistency, so an INSERT ... RETURNING re-selects the just-inserted row
|
|
619
|
-
-- by its REAL primary key, never a hardcoded ``id`` (FB-RETURNING-ID).
|
|
620
|
-
A table whose PK is not named ``id`` used to raise a Firebird 'Dynamic
|
|
621
|
-
SQL Error' from ``WHERE id = ?``. Introspected via :meth:`get_columns`
|
|
622
|
-
and cached per table. Returns ``None`` for a composite or key-less table
|
|
623
|
-
(which degrades to the generator last-id with no re-select rather than a
|
|
624
|
-
wrong one). The same defect+fix feature 10 applied to mysql.py and
|
|
625
|
-
feature 11 to mssql.py.
|
|
626
|
-
"""
|
|
627
|
-
cache = self.__dict__.setdefault("_returning_pk_cache", {})
|
|
628
|
-
if table not in cache:
|
|
629
|
-
try:
|
|
630
|
-
pks = [c["name"] for c in self.get_columns(table) if c.get("primary_key")]
|
|
631
|
-
except Exception: # noqa: BLE001 - no introspection => no re-select
|
|
632
|
-
pks = []
|
|
633
|
-
cache[table] = pks[0] if len(pks) == 1 else None
|
|
634
|
-
return cache[table]
|
|
635
|
-
|
|
636
651
|
# -- SQL Translation -----------------------------------------------
|
|
637
652
|
|
|
638
653
|
def _translate_sql(self, sql: str) -> str:
|
|
@@ -319,23 +319,6 @@ class MSSQLAdapter(SqlCrudMixin, DatabaseAdapter):
|
|
|
319
319
|
|
|
320
320
|
return q(table) if schema is None else f"{q(schema)}.{q(table)}"
|
|
321
321
|
|
|
322
|
-
def _returning_pk(self, table: str) -> str | None:
|
|
323
|
-
"""The table's single PRIMARY KEY column, for RETURNING emulation.
|
|
324
|
-
|
|
325
|
-
SQL Server has no RETURNING, so the provider re-selects the inserted row
|
|
326
|
-
by its REAL primary key - never a hardcoded ``id`` (MSSQL-RETURNING-ID).
|
|
327
|
-
Introspected via :meth:`get_columns` and cached per table. Returns
|
|
328
|
-
``None`` when the table has no single-column PK (a composite or key-less
|
|
329
|
-
table degrades to no re-select rather than a wrong one)."""
|
|
330
|
-
cache = self.__dict__.setdefault("_returning_pk_cache", {})
|
|
331
|
-
if table not in cache:
|
|
332
|
-
try:
|
|
333
|
-
pks = [c["name"] for c in self.get_columns(table) if c.get("primary_key")]
|
|
334
|
-
except Exception:
|
|
335
|
-
pks = []
|
|
336
|
-
cache[table] = pks[0] if len(pks) == 1 else None
|
|
337
|
-
return cache[table]
|
|
338
|
-
|
|
339
322
|
# -- SQL Translation -----------------------------------------------
|
|
340
323
|
|
|
341
324
|
def _translate_sql(self, sql: str) -> str:
|
|
@@ -289,23 +289,6 @@ class MySQLAdapter(SqlCrudMixin, DatabaseAdapter):
|
|
|
289
289
|
|
|
290
290
|
return q(table) if schema is None else f"{q(schema)}.{q(table)}"
|
|
291
291
|
|
|
292
|
-
def _returning_pk(self, table: str) -> str | None:
|
|
293
|
-
"""The table's single PRIMARY KEY column, for RETURNING emulation.
|
|
294
|
-
|
|
295
|
-
MySQL has no RETURNING, so the provider re-selects the inserted row by
|
|
296
|
-
its REAL primary key - never a hardcoded ``id`` (MYSQL-RETURNING-ID).
|
|
297
|
-
Introspected via :meth:`get_columns` and cached per table. Returns
|
|
298
|
-
``None`` when the table has no single-column PK (a composite or key-less
|
|
299
|
-
table degrades to no re-select rather than a wrong one)."""
|
|
300
|
-
cache = self.__dict__.setdefault("_returning_pk_cache", {})
|
|
301
|
-
if table not in cache:
|
|
302
|
-
try:
|
|
303
|
-
pks = [c["name"] for c in self.get_columns(table) if c.get("primary_key")]
|
|
304
|
-
except Exception:
|
|
305
|
-
pks = []
|
|
306
|
-
cache[table] = pks[0] if len(pks) == 1 else None
|
|
307
|
-
return cache[table]
|
|
308
|
-
|
|
309
292
|
def get_database_type(self) -> str:
|
|
310
293
|
return "mysql"
|
|
311
294
|
|
|
@@ -499,13 +499,6 @@ def get_api_handlers() -> dict:
|
|
|
499
499
|
"/__dev/api/websockets": ("GET", _api_websockets),
|
|
500
500
|
"/__dev/api/websockets/disconnect": ("POST", _api_ws_disconnect),
|
|
501
501
|
"/__dev/api/system": ("GET", _api_system),
|
|
502
|
-
"/__dev/api/chat": ("POST", _api_chat),
|
|
503
|
-
# Thread CRUD — exact match handles GET (list) + POST (create);
|
|
504
|
-
# the "/*" key is the prefix-fallback for /threads/{id}[/messages]
|
|
505
|
-
# which the dev_admin dispatcher routes to _api_threads_sub.
|
|
506
|
-
# "*" method = handler switches on request.method itself.
|
|
507
|
-
"/__dev/api/threads": ("*", _api_threads),
|
|
508
|
-
"/__dev/api/threads/*": ("*", _api_threads_sub),
|
|
509
502
|
# ── Customer feedback widget (Tier 1: intake-only) ──
|
|
510
503
|
# /__feedback/widget.js — bundle served at top-level so HTML
|
|
511
504
|
# pages can embed without a /__dev path (the widget is for
|
|
@@ -529,18 +522,6 @@ def get_api_handlers() -> dict:
|
|
|
529
522
|
"/embed": ("GET", _api_service_embed),
|
|
530
523
|
"/image": ("GET", _api_service_image),
|
|
531
524
|
"/rag": ("GET", _api_service_rag),
|
|
532
|
-
# Thoughts — proxied to the rust agent server when it's running.
|
|
533
|
-
"/__dev/api/thoughts": ("GET", _api_thoughts),
|
|
534
|
-
# Supervisor orchestration — transparent proxy to the rust
|
|
535
|
-
# agent server (port = framework port + 2000). When tina4
|
|
536
|
-
# serve isn't running these respond 503 with a helpful hint
|
|
537
|
-
# instead of failing silently.
|
|
538
|
-
"/__dev/api/supervise/create": ("POST", _api_supervise_create),
|
|
539
|
-
"/__dev/api/supervise/sessions": ("GET", _api_supervise_sessions),
|
|
540
|
-
"/__dev/api/supervise/diff": ("GET", _api_supervise_diff),
|
|
541
|
-
"/__dev/api/supervise/commit": ("POST", _api_supervise_commit),
|
|
542
|
-
"/__dev/api/supervise/cancel": ("POST", _api_supervise_cancel),
|
|
543
|
-
"/__dev/api/execute": ("POST", _api_execute),
|
|
544
525
|
"/__dev/api/tool": ("POST", _api_tool),
|
|
545
526
|
"/__dev/api/connections": ("GET", _api_connections),
|
|
546
527
|
"/__dev/api/connections/test": ("POST", _api_connections_test),
|
|
@@ -1315,15 +1296,15 @@ async def _api_service_rag(request, response):
|
|
|
1315
1296
|
def _supervisor_base_url() -> str:
|
|
1316
1297
|
"""Return the URL of the co-located rust agent server, if any.
|
|
1317
1298
|
|
|
1318
|
-
|
|
1299
|
+
Still used by the customer-feedback widget (``/__feedback/api/turn``),
|
|
1300
|
+
which forwards to the agent's ``/feedback/intake``. Resolution order
|
|
1301
|
+
(first match wins):
|
|
1319
1302
|
|
|
1320
1303
|
1. `TINA4_SUPERVISOR_URL` — full URL for non-localhost deployments.
|
|
1321
1304
|
2. `TINA4_AGENT_PORT` — explicit port override.
|
|
1322
1305
|
3. `PORT + 2000` — auto-derived. `tina4 serve` exports the
|
|
1323
1306
|
framework's PORT into the child process AND spawns the agent
|
|
1324
|
-
on `port + 2000` (main.rs::handle_serve).
|
|
1325
|
-
keeps both sides aligned automatically: Python on 7146 →
|
|
1326
|
-
agent on 9146, Node on 7148 → agent on 9148, etc.
|
|
1307
|
+
on `port + 2000` (main.rs::handle_serve).
|
|
1327
1308
|
4. Hardcoded 9145 — matches `tina4 agent` standalone's default
|
|
1328
1309
|
(main.rs::handle_agent), for users running the agent without
|
|
1329
1310
|
going through `tina4 serve`.
|
|
@@ -1343,174 +1324,6 @@ def _supervisor_base_url() -> str:
|
|
|
1343
1324
|
return "http://127.0.0.1:9145"
|
|
1344
1325
|
|
|
1345
1326
|
|
|
1346
|
-
async def _proxy_to_supervisor(request, response, downstream_path: str):
|
|
1347
|
-
"""Forward a dev-admin request to the rust agent server.
|
|
1348
|
-
|
|
1349
|
-
The SPA's supervisor UI calls paths like ``/__dev/api/supervise/create``;
|
|
1350
|
-
we strip the ``/__dev/api`` prefix and POST/GET the same body+query to
|
|
1351
|
-
the agent server. Returns the agent's response verbatim. When the
|
|
1352
|
-
agent isn't reachable (tina4 serve not running, or bare framework)
|
|
1353
|
-
we respond with a specific 503 so the SPA can show a useful error
|
|
1354
|
-
instead of silently doing nothing.
|
|
1355
|
-
"""
|
|
1356
|
-
import urllib.request
|
|
1357
|
-
import urllib.error
|
|
1358
|
-
|
|
1359
|
-
base = _supervisor_base_url()
|
|
1360
|
-
qs = ""
|
|
1361
|
-
try:
|
|
1362
|
-
if hasattr(request, "query") and request.query:
|
|
1363
|
-
from urllib.parse import urlencode
|
|
1364
|
-
qs = "?" + urlencode(request.query)
|
|
1365
|
-
except Exception:
|
|
1366
|
-
qs = ""
|
|
1367
|
-
target = f"{base}{downstream_path}{qs}"
|
|
1368
|
-
|
|
1369
|
-
method = (getattr(request, "method", "GET") or "GET").upper()
|
|
1370
|
-
data = None
|
|
1371
|
-
if method in ("POST", "PUT", "PATCH", "DELETE"):
|
|
1372
|
-
body = getattr(request, "body", None)
|
|
1373
|
-
if isinstance(body, dict):
|
|
1374
|
-
# SPA→agent convention fixup: `/execute` sends plan_file as
|
|
1375
|
-
# a bare filename but the rust agent expects a project-relative
|
|
1376
|
-
# path. Prepend `plan/` when no slash is present.
|
|
1377
|
-
pf = body.get("plan_file")
|
|
1378
|
-
if isinstance(pf, str) and pf and "/" not in pf:
|
|
1379
|
-
body = dict(body)
|
|
1380
|
-
body["plan_file"] = f"plan/{pf}"
|
|
1381
|
-
data = json.dumps(body).encode()
|
|
1382
|
-
elif isinstance(body, list):
|
|
1383
|
-
data = json.dumps(body).encode()
|
|
1384
|
-
elif isinstance(body, str) and body:
|
|
1385
|
-
data = body.encode()
|
|
1386
|
-
|
|
1387
|
-
import asyncio
|
|
1388
|
-
|
|
1389
|
-
req = urllib.request.Request(
|
|
1390
|
-
target,
|
|
1391
|
-
data=data,
|
|
1392
|
-
method=method,
|
|
1393
|
-
headers={"Content-Type": "application/json"},
|
|
1394
|
-
)
|
|
1395
|
-
# /execute and /chat run the multi-agent loop — supervisor +
|
|
1396
|
-
# planner + coder, each a multi-second LLM call. Other
|
|
1397
|
-
# supervise/* calls are metadata-only and return fast, so a
|
|
1398
|
-
# generous shared timeout for the heavy ones is cheaper than
|
|
1399
|
-
# branching on every endpoint.
|
|
1400
|
-
timeout = 600 if downstream_path in ("/execute", "/chat") else 30
|
|
1401
|
-
|
|
1402
|
-
# Open the upstream connection in a thread — urlopen is blocking.
|
|
1403
|
-
# We DON'T read the body here; that's done below either in one shot
|
|
1404
|
-
# (JSON path) or chunk-by-chunk (SSE path) so progress events reach
|
|
1405
|
-
# the SPA live instead of after the whole 30-second supervisor run.
|
|
1406
|
-
try:
|
|
1407
|
-
upstream = await asyncio.to_thread(
|
|
1408
|
-
urllib.request.urlopen, req, None, timeout
|
|
1409
|
-
)
|
|
1410
|
-
except urllib.error.HTTPError as e:
|
|
1411
|
-
err_body = e.read().decode() if e.fp else str(e)
|
|
1412
|
-
try:
|
|
1413
|
-
return response(json.loads(err_body), e.code)
|
|
1414
|
-
except Exception:
|
|
1415
|
-
return response({"error": err_body[:300]}, e.code)
|
|
1416
|
-
except Exception as e:
|
|
1417
|
-
return response({
|
|
1418
|
-
"error": "supervisor unavailable",
|
|
1419
|
-
"detail": str(e),
|
|
1420
|
-
"hint": "Run `tina4 serve` (starts the agent server) or set TINA4_SUPERVISOR_URL",
|
|
1421
|
-
}, 503)
|
|
1422
|
-
|
|
1423
|
-
upstream_ct = upstream.headers.get("Content-Type", "") or ""
|
|
1424
|
-
|
|
1425
|
-
# SSE / event-stream: stream chunks through as they arrive. Without
|
|
1426
|
-
# this the SPA sees nothing until the supervisor's entire multi-agent
|
|
1427
|
-
# run completes (30s+), then a wall of events at once — looks like
|
|
1428
|
-
# "Connection failed". urlopen.read(n) is blocking, so each read goes
|
|
1429
|
-
# through asyncio.to_thread to keep the event loop responsive.
|
|
1430
|
-
if "text/event-stream" in upstream_ct.lower():
|
|
1431
|
-
async def _relay():
|
|
1432
|
-
try:
|
|
1433
|
-
while True:
|
|
1434
|
-
chunk = await asyncio.to_thread(upstream.read, 4096)
|
|
1435
|
-
if not chunk:
|
|
1436
|
-
break
|
|
1437
|
-
yield chunk
|
|
1438
|
-
finally:
|
|
1439
|
-
upstream.close()
|
|
1440
|
-
return response.stream(_relay(), "text/event-stream")
|
|
1441
|
-
|
|
1442
|
-
# JSON / other: drain the body and return as before.
|
|
1443
|
-
try:
|
|
1444
|
-
raw = await asyncio.to_thread(upstream.read)
|
|
1445
|
-
finally:
|
|
1446
|
-
upstream.close()
|
|
1447
|
-
try:
|
|
1448
|
-
return response(json.loads(raw))
|
|
1449
|
-
except Exception:
|
|
1450
|
-
return response(raw.decode("utf-8", errors="replace"))
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
async def _api_supervise_create(request, response):
|
|
1454
|
-
"""Before forwarding to the rust agent, auto-flesh the current plan
|
|
1455
|
-
if it has zero steps. The SPA sends the user's supervisor-chat
|
|
1456
|
-
message as ``title``/``plan``; we use that as the fleshing prompt.
|
|
1457
|
-
Skipped when the plan already has steps so populated plans aren't
|
|
1458
|
-
polluted. Best-effort — never blocks supervise/create."""
|
|
1459
|
-
try:
|
|
1460
|
-
from tina4_python.dev_admin import plan as _plan
|
|
1461
|
-
current = _plan.current()
|
|
1462
|
-
is_empty = (
|
|
1463
|
-
isinstance(current.get("current"), str)
|
|
1464
|
-
and (current.get("progress") or {}).get("total", 0) == 0
|
|
1465
|
-
)
|
|
1466
|
-
if is_empty:
|
|
1467
|
-
body = getattr(request, "body", None) or {}
|
|
1468
|
-
if isinstance(body, dict):
|
|
1469
|
-
prompt = str(body.get("plan") or body.get("title") or "").strip()
|
|
1470
|
-
if prompt:
|
|
1471
|
-
_plan.flesh(current["current"], prompt)
|
|
1472
|
-
except Exception:
|
|
1473
|
-
pass # Fleshing is best-effort.
|
|
1474
|
-
return await _proxy_to_supervisor(request, response, "/supervise/create")
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
async def _api_supervise_sessions(request, response):
|
|
1478
|
-
return await _proxy_to_supervisor(request, response, "/supervise/sessions")
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
async def _api_supervise_diff(request, response):
|
|
1482
|
-
return await _proxy_to_supervisor(request, response, "/supervise/diff")
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
async def _api_supervise_commit(request, response):
|
|
1486
|
-
return await _proxy_to_supervisor(request, response, "/supervise/commit")
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
async def _api_supervise_cancel(request, response):
|
|
1490
|
-
return await _proxy_to_supervisor(request, response, "/supervise/cancel")
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
async def _api_execute(request, response):
|
|
1494
|
-
return await _proxy_to_supervisor(request, response, "/execute")
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
async def _api_thoughts(request, response):
|
|
1498
|
-
"""Thoughts — proxied to the rust agent server when it's running,
|
|
1499
|
-
otherwise an empty list so the SPA renders gracefully."""
|
|
1500
|
-
base = _supervisor_base_url()
|
|
1501
|
-
import urllib.request
|
|
1502
|
-
import urllib.error
|
|
1503
|
-
try:
|
|
1504
|
-
with urllib.request.urlopen(f"{base}/thoughts", timeout=5) as r:
|
|
1505
|
-
raw = r.read()
|
|
1506
|
-
try:
|
|
1507
|
-
return response(json.loads(raw))
|
|
1508
|
-
except Exception:
|
|
1509
|
-
return response({"thoughts": []})
|
|
1510
|
-
except Exception:
|
|
1511
|
-
return response({"thoughts": []})
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
1327
|
async def _api_ai_proxy(request, response):
|
|
1515
1328
|
"""Transparent pass-through to the qwen ollama chat endpoint.
|
|
1516
1329
|
|
|
@@ -1798,56 +1611,6 @@ async def _api_feedback_widget_js(request, response):
|
|
|
1798
1611
|
return response(body, 200, "application/javascript")
|
|
1799
1612
|
|
|
1800
1613
|
|
|
1801
|
-
async def _api_threads(request, response):
|
|
1802
|
-
"""Proxy /__dev/api/threads → Rust agent /threads (GET list, POST create).
|
|
1803
|
-
|
|
1804
|
-
Method-multiplexed handler — the dev_admin dispatcher registered this
|
|
1805
|
-
under the "*" method wildcard so one entry point serves both list and
|
|
1806
|
-
create. Anything other than GET/POST gets a 405.
|
|
1807
|
-
"""
|
|
1808
|
-
method = (getattr(request, "method", "GET") or "GET").upper()
|
|
1809
|
-
if method not in ("GET", "POST"):
|
|
1810
|
-
return response({"error": "method not allowed"}, 405)
|
|
1811
|
-
return await _proxy_to_supervisor(request, response, "/threads")
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
async def _api_threads_sub(request, response):
|
|
1815
|
-
"""Proxy /__dev/api/threads/{id}[/messages] → Rust agent.
|
|
1816
|
-
|
|
1817
|
-
Catches everything beneath /__dev/api/threads/ via the dispatcher's
|
|
1818
|
-
"/*" prefix match. We strip the dev-admin prefix and forward the
|
|
1819
|
-
remaining path verbatim so /__dev/api/threads/abc/messages becomes
|
|
1820
|
-
/threads/abc/messages on the agent side.
|
|
1821
|
-
"""
|
|
1822
|
-
path = getattr(request, "path", "") or ""
|
|
1823
|
-
suffix = path[len("/__dev/api"):] # leaves "/threads/abc[/messages]"
|
|
1824
|
-
if not suffix.startswith("/threads/"):
|
|
1825
|
-
return response({"error": "not found"}, 404)
|
|
1826
|
-
return await _proxy_to_supervisor(request, response, suffix)
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
async def _api_chat(request, response):
|
|
1830
|
-
"""Proxy dev-admin chat to the Rust agent server's /chat endpoint.
|
|
1831
|
-
|
|
1832
|
-
The SPA (Chat.ts) POSTs `{message, settings, files?}` and expects an
|
|
1833
|
-
SSE stream of `event: status / message / done` chunks. The Rust agent
|
|
1834
|
-
runs the supervisor → planner → coder loop, calls the configured LLM
|
|
1835
|
-
(Anthropic / OpenAI / Tina4 Cloud — driven by `ChatSettings` from
|
|
1836
|
-
`.tina4/chat/settings.json` or the ANTHROPIC_API_KEY env-var
|
|
1837
|
-
defaults), and streams progress back.
|
|
1838
|
-
|
|
1839
|
-
Earlier versions of this endpoint called qwen on the Tina4 Cloud
|
|
1840
|
-
directly and returned a single JSON blob, which broke the SPA's SSE
|
|
1841
|
-
reader and bypassed every configured model setting. If you want the
|
|
1842
|
-
bare qwen path it's still reachable at /ai/api/chat.
|
|
1843
|
-
|
|
1844
|
-
When `tina4 agent` isn't running, _proxy_to_supervisor returns 503
|
|
1845
|
-
with a helpful hint so the SPA can show a real error instead of
|
|
1846
|
-
silently hanging.
|
|
1847
|
-
"""
|
|
1848
|
-
return await _proxy_to_supervisor(request, response, "/chat")
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
1614
|
async def _api_tool(request, response):
|
|
1852
1615
|
"""Run a developer tool and return output."""
|
|
1853
1616
|
import subprocess
|
|
@@ -2282,7 +2045,16 @@ def render_dev_toolbar(method: str, path: str, matched_pattern: str,
|
|
|
2282
2045
|
path = _html_escape(str(path), quote=True)
|
|
2283
2046
|
matched_pattern = _html_escape(str(matched_pattern), quote=True)
|
|
2284
2047
|
request_id = _html_escape(str(request_id), quote=True)
|
|
2285
|
-
|
|
2048
|
+
# The dev-admin dashboard (any /__dev page) owns its OWN gentle reload watcher
|
|
2049
|
+
# inside the SPA (git-status refresh in place), so the toolbar's blunt
|
|
2050
|
+
# location.reload() must be suppressed there -- otherwise saving a file from
|
|
2051
|
+
# the dashboard's editor reloads the whole dashboard and stomps the editing
|
|
2052
|
+
# session. App pages keep the normal hot-reload.
|
|
2053
|
+
no_reload = (
|
|
2054
|
+
os.environ.get("TINA4_NO_RELOAD", "").lower() in ("true", "1", "yes")
|
|
2055
|
+
or _ai_port_ctx.get()
|
|
2056
|
+
or str(path).startswith("/__dev")
|
|
2057
|
+
)
|
|
2286
2058
|
reload = "0" if no_reload else "1"
|
|
2287
2059
|
|
|
2288
2060
|
return f"""<link rel="stylesheet" href="/__dev/toolbar.css">
|
|
@@ -45,35 +45,6 @@ class _Unsupported(Exception):
|
|
|
45
45
|
so the caller uses the interpreted renderer for the whole template."""
|
|
46
46
|
|
|
47
47
|
|
|
48
|
-
def _tostr(value) -> str:
|
|
49
|
-
"""Coerce a rendered value to its output string.
|
|
50
|
-
|
|
51
|
-
THE cross-framework output contract, and it MUST stay identical to the
|
|
52
|
-
interpreter's copy in ``engine.Frond._to_output`` -- the compiled path and the
|
|
53
|
-
interpreted path have to render the same bytes. Change one, change both.
|
|
54
|
-
|
|
55
|
-
A boolean renders lowercase ``true``/``false``: Frond is a template language,
|
|
56
|
-
not Python, and lowercase is the form usable directly in HTML and JavaScript
|
|
57
|
-
(``data-active="{{ flag }}"`` -> ``data-active="true"``, testable from JS).
|
|
58
|
-
|
|
59
|
-
Breaking in 3.13.87: this used to emit Python's ``True``/``False``. The four
|
|
60
|
-
frameworks had drifted to four different answers -- Python ``True``/``False``
|
|
61
|
-
(Jinja2-faithful), PHP ``1``/``''`` (Twig-faithful, with ``false`` rendering as
|
|
62
|
-
an EMPTY STRING), Ruby inconsistent between a comparison and a bare variable,
|
|
63
|
-
Node ``true``/``false``. All four now agree; a 72-expression corpus locks it.
|
|
64
|
-
|
|
65
|
-
``is True`` / ``is False`` identity checks are deliberate: ``1 == True`` in
|
|
66
|
-
Python, and an integer 1 must still render as ``1``.
|
|
67
|
-
"""
|
|
68
|
-
if value is None:
|
|
69
|
-
return ""
|
|
70
|
-
if value is True:
|
|
71
|
-
return "true"
|
|
72
|
-
if value is False:
|
|
73
|
-
return "false"
|
|
74
|
-
return str(value)
|
|
75
|
-
|
|
76
|
-
|
|
77
48
|
def _pad(indent: int) -> str:
|
|
78
49
|
return " " * indent
|
|
79
50
|
|
|
@@ -99,7 +70,12 @@ def compile_template(ast):
|
|
|
99
70
|
"_LoopContext": _engine._LoopContext,
|
|
100
71
|
"_eval_expr": _engine._eval_expr,
|
|
101
72
|
"_eval_comparison": _engine._eval_comparison,
|
|
102
|
-
|
|
73
|
+
# The compiled path coerces output through the interpreter's OWN
|
|
74
|
+
# Frond._to_output -- single source of truth for the cross-framework
|
|
75
|
+
# output contract, so the compiled and interpreted paths cannot drift
|
|
76
|
+
# (they were byte-identical copies with a "change one, change both"
|
|
77
|
+
# hazard). Bound here, at compile time, so there is no per-render cost.
|
|
78
|
+
"_tostr": _engine.Frond._to_output,
|
|
103
79
|
"isinstance": isinstance,
|
|
104
80
|
"list": list,
|
|
105
81
|
"len": len,
|