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.
Files changed (172) hide show
  1. {tina4_python-3.13.131 → tina4_python-3.13.133}/PKG-INFO +1 -1
  2. {tina4_python-3.13.131 → tina4_python-3.13.133}/pyproject.toml +1 -1
  3. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/__init__.py +1 -1
  4. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/cli/__init__.py +4 -9
  5. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/core/server.py +70 -1
  6. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/database/adapter.py +24 -0
  7. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/database/firebird.py +38 -23
  8. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/database/mssql.py +0 -17
  9. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/database/mysql.py +0 -17
  10. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/dev_admin/__init__.py +14 -242
  11. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/frond/compiler.py +6 -30
  12. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/frond/engine.py +4 -3
  13. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/orm/__init__.py +2 -0
  14. tina4_python-3.13.133/tina4_python/orm/collection.py +82 -0
  15. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/orm/model.py +25 -31
  16. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/public/js/tina4-dev-admin.min.js +148 -512
  17. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/session_handlers/redis_handler.py +19 -10
  18. tina4_python-3.13.133/tina4_python/session_handlers/valkey_handler.py +28 -0
  19. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/swagger/__init__.py +227 -175
  20. tina4_python-3.13.131/tina4_python/session_handlers/valkey_handler.py +0 -211
  21. {tina4_python-3.13.131 → tina4_python-3.13.133}/.gitignore +0 -0
  22. {tina4_python-3.13.131 → tina4_python-3.13.133}/README.md +0 -0
  23. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/CLAUDE.md +0 -0
  24. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/HtmlElement.py +0 -0
  25. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/Testing.py +0 -0
  26. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/_import_helper.py +0 -0
  27. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/ai/__init__.py +0 -0
  28. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/ai/client.py +0 -0
  29. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/api/__init__.py +0 -0
  30. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/auth/__init__.py +0 -0
  31. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/cache/__init__.py +0 -0
  32. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/container/__init__.py +0 -0
  33. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/context/__init__.py +0 -0
  34. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/context/chunker.py +0 -0
  35. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/core/__init__.py +0 -0
  36. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/core/cache.py +0 -0
  37. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/core/constants.py +0 -0
  38. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/core/events.py +0 -0
  39. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/core/middleware.py +0 -0
  40. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/core/port_takeover.py +0 -0
  41. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/core/rate_limiter.py +0 -0
  42. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/core/request.py +0 -0
  43. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/core/response.py +0 -0
  44. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/core/router.py +0 -0
  45. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/crud/__init__.py +0 -0
  46. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/database/__init__.py +0 -0
  47. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/database/connection.py +0 -0
  48. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/database/database_url.py +0 -0
  49. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/database/mongodb.py +0 -0
  50. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/database/odbc.py +0 -0
  51. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/database/postgres.py +0 -0
  52. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/database/sql_translator.py +0 -0
  53. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/database/sqlite.py +0 -0
  54. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/debug/__init__.py +0 -0
  55. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/debug/error_overlay.py +0 -0
  56. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/dev_admin/metrics.py +0 -0
  57. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/dev_admin/plan.py +0 -0
  58. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/dev_admin/project_index.py +0 -0
  59. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/docs.py +0 -0
  60. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/docstore/__init__.py +0 -0
  61. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/dotenv/__init__.py +0 -0
  62. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/env.py +0 -0
  63. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/frond/FROND.md +0 -0
  64. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/frond/__init__.py +0 -0
  65. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/frond/parser.py +0 -0
  66. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/gallery/auth/meta.json +0 -0
  67. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  68. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/gallery/database/meta.json +0 -0
  69. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  70. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/gallery/error-overlay/meta.json +0 -0
  71. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  72. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/gallery/orm/meta.json +0 -0
  73. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  74. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  75. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/gallery/queue/meta.json +0 -0
  76. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  77. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/gallery/rest-api/meta.json +0 -0
  78. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  79. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/gallery/templates/meta.json +0 -0
  80. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  81. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  82. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/graph/__init__.py +0 -0
  83. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/graph/adapter.py +0 -0
  84. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/graph/adapters/__init__.py +0 -0
  85. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/graph/adapters/arango.py +0 -0
  86. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/graph/adapters/bolt.py +0 -0
  87. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/graph/adapters/ultipa.py +0 -0
  88. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/graph/graph_url.py +0 -0
  89. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/graphql/__init__.py +0 -0
  90. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/i18n/__init__.py +0 -0
  91. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/mcp/__init__.py +0 -0
  92. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/mcp/protocol.py +0 -0
  93. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/mcp/tools.py +0 -0
  94. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/messenger/__init__.py +0 -0
  95. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/migration/__init__.py +0 -0
  96. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/migration/runner.py +0 -0
  97. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/mqtt/__init__.py +0 -0
  98. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/mqtt/message.py +0 -0
  99. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/orm/fields.py +0 -0
  100. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/orm/point.py +0 -0
  101. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/public/__feedback/widget.js +0 -0
  102. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/public/css/tina4.css +0 -0
  103. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/public/css/tina4.min.css +0 -0
  104. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/public/favicon.ico +0 -0
  105. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/public/images/logo.svg +0 -0
  106. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  107. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/public/js/frond.js +0 -0
  108. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/public/js/frond.min.js +0 -0
  109. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/public/js/tina4.min.js +0 -0
  110. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/public/js/tina4js.min.js +0 -0
  111. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/public/swagger/index.html +0 -0
  112. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  113. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/query_builder/__init__.py +0 -0
  114. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/queue/__init__.py +0 -0
  115. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/queue/amqp_url.py +0 -0
  116. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/queue/job.py +0 -0
  117. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/queue/kafka_backend.py +0 -0
  118. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/queue/lite_backend.py +0 -0
  119. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/queue/mongo_backend.py +0 -0
  120. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/queue/rabbitmq_backend.py +0 -0
  121. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/queue_backends/__init__.py +0 -0
  122. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/queue_backends/kafka_backend.py +0 -0
  123. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/queue_backends/mongo_backend.py +0 -0
  124. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  125. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/realtime/__init__.py +0 -0
  126. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/realtime/models/Attachment.py +0 -0
  127. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/realtime/models/Channel.py +0 -0
  128. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/realtime/models/ChannelMember.py +0 -0
  129. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/realtime/models/Message.py +0 -0
  130. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/realtime/models/Workspace.py +0 -0
  131. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/realtime/models/__init__.py +0 -0
  132. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/realtime/storage.py +0 -0
  133. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/seeder/__init__.py +0 -0
  134. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/service/__init__.py +0 -0
  135. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/session/__init__.py +0 -0
  136. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/session_handlers/__init__.py +0 -0
  137. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/session_handlers/memcached_handler.py +0 -0
  138. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  139. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/sso.py +0 -0
  140. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/templates/components/crud.twig +0 -0
  141. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  142. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  143. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/templates/docker/python/Dockerfile +0 -0
  144. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  145. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/templates/errors/302.twig +0 -0
  146. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/templates/errors/401.twig +0 -0
  147. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/templates/errors/403.twig +0 -0
  148. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/templates/errors/404.twig +0 -0
  149. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/templates/errors/500.twig +0 -0
  150. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/templates/errors/502.twig +0 -0
  151. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/templates/errors/503.twig +0 -0
  152. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/templates/errors/base.twig +0 -0
  153. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/templates/frontend/README.md +0 -0
  154. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/templates/readme.md +0 -0
  155. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/test/__init__.py +0 -0
  156. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/test_client/__init__.py +0 -0
  157. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  158. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  159. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  160. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  161. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  162. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  163. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  164. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  165. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  166. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  167. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  168. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  169. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/validator/__init__.py +0 -0
  170. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/websocket/__init__.py +0 -0
  171. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/websocket/backplane.py +0 -0
  172. {tina4_python-3.13.131 → tina4_python-3.13.133}/tina4_python/wsdl/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tina4-python
3
- Version: 3.13.131
3
+ Version: 3.13.133
4
4
  Summary: Tina4 Python v3 — Zero-dependency, lightweight web framework
5
5
  Author-email: Andre van Zuydam <andrevanzuydam@gmail.com>
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tina4-python"
3
- version = "3.13.131"
3
+ version = "3.13.133"
4
4
  description = "Tina4 Python v3 — Zero-dependency, lightweight web framework"
5
5
  authors = [
6
6
  {name = "Andre van Zuydam", email = "andrevanzuydam@gmail.com"}
@@ -51,7 +51,7 @@ def _resolve_version() -> str:
51
51
  #
52
52
  # test_version_constant.py now asserts this literal equals the pyproject
53
53
  # version, so the release bump cannot leave it behind again.
54
- return "3.13.131"
54
+ return "3.13.133"
55
55
 
56
56
 
57
57
  __version__ = _resolve_version()
@@ -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, total = {model}.where("1=1", limit=per_page, offset=offset, with_count=True)
1798
+ records = {model}.where("1=1", limit=per_page, offset=offset)
1799
1799
  # tina4:edit customise the list projection (filters, ordering, fields)
1800
- return response({{
1801
- "records": [r.to_dict() for r in records],
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, with_count=True) (import {m} from src.orm.{m})",
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 = [s for s in (ai_server, server) if s is not None]
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
- affected = cursor.rowcount if cursor.rowcount >= 0 else 0
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
- Resolution order (first match wins):
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). Reading PORT here
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
- no_reload = os.environ.get("TINA4_NO_RELOAD", "").lower() in ("true", "1", "yes") or _ai_port_ctx.get()
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
- "_tostr": _tostr,
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,