blq-cli 1.0.0__tar.gz → 1.0.2__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 (201) hide show
  1. blq_cli-1.0.2/CHANGELOG.md +6 -0
  2. {blq_cli-1.0.0 → blq_cli-1.0.2}/PKG-INFO +3 -3
  3. blq_cli-1.0.2/docs/plans/explore-bird-sqlite-backend.md +105 -0
  4. {blq_cli-1.0.0 → blq_cli-1.0.2}/pyproject.toml +3 -3
  5. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq/__init__.py +1 -1
  6. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq/bird.py +112 -40
  7. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq/cli.py +2 -6
  8. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq/commands/core.py +1 -2
  9. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq/commands/events.py +4 -1
  10. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq/commands/execution.py +28 -26
  11. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq/commands/init_cmd.py +10 -2
  12. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq/commands/mcp_cmd.py +61 -0
  13. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq/commands/sandbox_cmd.py +5 -5
  14. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq/commands/sync_cmd.py +4 -1
  15. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq/ext/__init__.py +1 -0
  16. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq/ext/annotator.py +5 -8
  17. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq/ext/discovery.py +1 -1
  18. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq/ext/local_executor.py +6 -5
  19. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq/ext/pipeline.py +3 -5
  20. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq/query.py +1 -1
  21. blq_cli-1.0.2/src/blq/runtime.py +152 -0
  22. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq/serve.py +77 -8
  23. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq/services/__init__.py +1 -0
  24. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq/services/query.py +30 -30
  25. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq/services/refs.py +3 -12
  26. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq/storage.py +1 -3
  27. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq_sandbox/__init__.py +4 -3
  28. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq_sandbox/engines.py +4 -7
  29. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq_sandbox/profile.py +21 -20
  30. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq_sandbox/source_annotator.py +16 -15
  31. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq_sandbox/spec.py +0 -1
  32. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq_sandbox/strace_parser.py +11 -16
  33. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq_sandbox/tighten.py +2 -2
  34. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq_sandbox/violations.py +13 -4
  35. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq_sandbox_bwrap/__init__.py +2 -3
  36. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq_sandbox_systemd/__init__.py +3 -7
  37. {blq_cli-1.0.0 → blq_cli-1.0.2}/tests/test_annotator.py +19 -11
  38. {blq_cli-1.0.0 → blq_cli-1.0.2}/tests/test_bird.py +1 -0
  39. {blq_cli-1.0.0 → blq_cli-1.0.2}/tests/test_bwrap_args.py +0 -3
  40. {blq_cli-1.0.0 → blq_cli-1.0.2}/tests/test_bwrap_engine.py +17 -18
  41. {blq_cli-1.0.0 → blq_cli-1.0.2}/tests/test_command_lock_field.py +1 -0
  42. blq_cli-1.0.2/tests/test_config_consumption.py +84 -0
  43. {blq_cli-1.0.0 → blq_cli-1.0.2}/tests/test_execution_locks.py +1 -1
  44. {blq_cli-1.0.0 → blq_cli-1.0.2}/tests/test_ext_discovery.py +2 -1
  45. {blq_cli-1.0.0 → blq_cli-1.0.2}/tests/test_ext_integration.py +33 -22
  46. {blq_cli-1.0.0 → blq_cli-1.0.2}/tests/test_ext_local_executor.py +1 -0
  47. {blq_cli-1.0.0 → blq_cli-1.0.2}/tests/test_ext_pipeline.py +9 -4
  48. {blq_cli-1.0.0 → blq_cli-1.0.2}/tests/test_ext_types.py +2 -1
  49. {blq_cli-1.0.0 → blq_cli-1.0.2}/tests/test_locks.py +8 -2
  50. blq_cli-1.0.2/tests/test_runtime_config.py +175 -0
  51. {blq_cli-1.0.0 → blq_cli-1.0.2}/tests/test_sandbox.py +8 -4
  52. {blq_cli-1.0.0 → blq_cli-1.0.2}/tests/test_sandbox_cmd.py +1 -0
  53. {blq_cli-1.0.0 → blq_cli-1.0.2}/tests/test_sandbox_events.py +47 -42
  54. {blq_cli-1.0.0 → blq_cli-1.0.2}/tests/test_sandbox_ext.py +6 -3
  55. {blq_cli-1.0.0 → blq_cli-1.0.2}/tests/test_sandbox_profile.py +1 -0
  56. {blq_cli-1.0.0 → blq_cli-1.0.2}/tests/test_sandbox_register.py +1 -2
  57. {blq_cli-1.0.0 → blq_cli-1.0.2}/tests/test_sandbox_systemd.py +6 -12
  58. {blq_cli-1.0.0 → blq_cli-1.0.2}/tests/test_sandbox_tighten.py +1 -2
  59. {blq_cli-1.0.0 → blq_cli-1.0.2}/tests/test_sandbox_violations.py +1 -2
  60. blq_cli-1.0.2/tests/test_schema_repair.py +95 -0
  61. {blq_cli-1.0.0 → blq_cli-1.0.2}/tests/test_services_execution.py +0 -3
  62. {blq_cli-1.0.0 → blq_cli-1.0.2}/tests/test_services_inspect.py +2 -5
  63. {blq_cli-1.0.0 → blq_cli-1.0.2}/tests/test_services_query.py +21 -7
  64. {blq_cli-1.0.0 → blq_cli-1.0.2}/tests/test_source_annotator.py +12 -21
  65. {blq_cli-1.0.0 → blq_cli-1.0.2}/tests/test_strace_parser.py +2 -2
  66. blq_cli-1.0.2/uv.lock +3011 -0
  67. {blq_cli-1.0.0 → blq_cli-1.0.2}/.claude/hooks/blq-suggest.sh +0 -0
  68. {blq_cli-1.0.0 → blq_cli-1.0.2}/.github/workflows/ci.yml +0 -0
  69. {blq_cli-1.0.0 → blq_cli-1.0.2}/.github/workflows/docs.yml +0 -0
  70. {blq_cli-1.0.0 → blq_cli-1.0.2}/.github/workflows/publish.yml +0 -0
  71. {blq_cli-1.0.0 → blq_cli-1.0.2}/.gitignore +0 -0
  72. {blq_cli-1.0.0 → blq_cli-1.0.2}/.mcp.json +0 -0
  73. {blq_cli-1.0.0 → blq_cli-1.0.2}/.readthedocs.yml +0 -0
  74. {blq_cli-1.0.0 → blq_cli-1.0.2}/AGENT.md +0 -0
  75. {blq_cli-1.0.0 → blq_cli-1.0.2}/AGENTS.md +0 -0
  76. {blq_cli-1.0.0 → blq_cli-1.0.2}/CLAUDE.md +0 -0
  77. {blq_cli-1.0.0 → blq_cli-1.0.2}/CONTRIBUTING.md +0 -0
  78. {blq_cli-1.0.0 → blq_cli-1.0.2}/README.md +0 -0
  79. {blq_cli-1.0.0 → blq_cli-1.0.2}/SKILL.md +0 -0
  80. {blq_cli-1.0.0 → blq_cli-1.0.2}/docs/api-reference.md +0 -0
  81. {blq_cli-1.0.0 → blq_cli-1.0.2}/docs/commands/capture.md +0 -0
  82. {blq_cli-1.0.0 → blq_cli-1.0.2}/docs/commands/ci.md +0 -0
  83. {blq_cli-1.0.0 → blq_cli-1.0.2}/docs/commands/completions.md +0 -0
  84. {blq_cli-1.0.0 → blq_cli-1.0.2}/docs/commands/errors.md +0 -0
  85. {blq_cli-1.0.0 → blq_cli-1.0.2}/docs/commands/exec.md +0 -0
  86. {blq_cli-1.0.0 → blq_cli-1.0.2}/docs/commands/filter.md +0 -0
  87. {blq_cli-1.0.0 → blq_cli-1.0.2}/docs/commands/index.md +0 -0
  88. {blq_cli-1.0.0 → blq_cli-1.0.2}/docs/commands/init.md +0 -0
  89. {blq_cli-1.0.0 → blq_cli-1.0.2}/docs/commands/maintenance.md +0 -0
  90. {blq_cli-1.0.0 → blq_cli-1.0.2}/docs/commands/query.md +0 -0
  91. {blq_cli-1.0.0 → blq_cli-1.0.2}/docs/commands/registry.md +0 -0
  92. {blq_cli-1.0.0 → blq_cli-1.0.2}/docs/commands/report.md +0 -0
  93. {blq_cli-1.0.0 → blq_cli-1.0.2}/docs/commands/run.md +0 -0
  94. {blq_cli-1.0.0 → blq_cli-1.0.2}/docs/commands/sql.md +0 -0
  95. {blq_cli-1.0.0 → blq_cli-1.0.2}/docs/commands/status.md +0 -0
  96. {blq_cli-1.0.0 → blq_cli-1.0.2}/docs/commands/sync.md +0 -0
  97. {blq_cli-1.0.0 → blq_cli-1.0.2}/docs/commands/watch.md +0 -0
  98. {blq_cli-1.0.0 → blq_cli-1.0.2}/docs/design/design-bird-migration.md +0 -0
  99. {blq_cli-1.0.0 → blq_cli-1.0.2}/docs/design/design-commands.md +0 -0
  100. {blq_cli-1.0.0 → blq_cli-1.0.2}/docs/design/design-config-command.md +0 -0
  101. {blq_cli-1.0.0 → blq_cli-1.0.2}/docs/design/design-extensions.md +0 -0
  102. {blq_cli-1.0.0 → blq_cli-1.0.2}/docs/design/design-git-integration.md +0 -0
  103. {blq_cli-1.0.0 → blq_cli-1.0.2}/docs/design/design-hooks-v2.md +0 -0
  104. {blq_cli-1.0.0 → blq_cli-1.0.2}/docs/design/design-live-inspection.md +0 -0
  105. {blq_cli-1.0.0 → blq_cli-1.0.2}/docs/design/design-parameterized-commands.md +0 -0
  106. {blq_cli-1.0.0 → blq_cli-1.0.2}/docs/design/design-run-args.md +0 -0
  107. {blq_cli-1.0.0 → blq_cli-1.0.2}/docs/design/design-sandbox-specs.md +0 -0
  108. {blq_cli-1.0.0 → blq_cli-1.0.2}/docs/design/design-sync.md +0 -0
  109. {blq_cli-1.0.0 → blq_cli-1.0.2}/docs/design/design-track-save.md +0 -0
  110. {blq_cli-1.0.0 → blq_cli-1.0.2}/docs/design/duck-hunt-v2-migration.md +0 -0
  111. {blq_cli-1.0.0 → blq_cli-1.0.2}/docs/design/duck-hunt-v3-migration.md +0 -0
  112. {blq_cli-1.0.0 → blq_cli-1.0.2}/docs/design/proposal-bird-v5.md +0 -0
  113. {blq_cli-1.0.0 → blq_cli-1.0.2}/docs/design/skill-inspect-enrichment.md +0 -0
  114. {blq_cli-1.0.0 → blq_cli-1.0.2}/docs/getting-started.md +0 -0
  115. {blq_cli-1.0.0 → blq_cli-1.0.2}/docs/index.md +0 -0
  116. {blq_cli-1.0.0 → blq_cli-1.0.2}/docs/integration.md +0 -0
  117. {blq_cli-1.0.0 → blq_cli-1.0.2}/docs/mcp.md +0 -0
  118. {blq_cli-1.0.0 → blq_cli-1.0.2}/docs/plans/explore-nsjail-python-wrapper.md +0 -0
  119. {blq_cli-1.0.0 → blq_cli-1.0.2}/docs/plans/explore-nsjail-spack-package.md +0 -0
  120. {blq_cli-1.0.0 → blq_cli-1.0.2}/docs/plans/patterns-integration-prompt.md +0 -0
  121. {blq_cli-1.0.0 → blq_cli-1.0.2}/docs/plans/roadmap-to-1.0.md +0 -0
  122. {blq_cli-1.0.0 → blq_cli-1.0.2}/docs/plugin-guide.md +0 -0
  123. {blq_cli-1.0.0 → blq_cli-1.0.2}/docs/python-api.md +0 -0
  124. {blq_cli-1.0.0 → blq_cli-1.0.2}/docs/query-guide.md +0 -0
  125. {blq_cli-1.0.0 → blq_cli-1.0.2}/docs/requirements.txt +0 -0
  126. {blq_cli-1.0.0 → blq_cli-1.0.2}/docs/sandbox.md +0 -0
  127. {blq_cli-1.0.0 → blq_cli-1.0.2}/docs/schema-stability.md +0 -0
  128. {blq_cli-1.0.0 → blq_cli-1.0.2}/docs/schema.md +0 -0
  129. {blq_cli-1.0.0 → blq_cli-1.0.2}/docs/superpowers/plans/2026-03-22-extension-system.md +0 -0
  130. {blq_cli-1.0.0 → blq_cli-1.0.2}/docs/superpowers/plans/2026-03-28-bwrap-engine.md +0 -0
  131. {blq_cli-1.0.0 → blq_cli-1.0.2}/docs/superpowers/plans/2026-03-28-command-locks.md +0 -0
  132. {blq_cli-1.0.0 → blq_cli-1.0.2}/docs/superpowers/plans/2026-03-29-annotators-and-tighten.md +0 -0
  133. {blq_cli-1.0.0 → blq_cli-1.0.2}/docs/superpowers/plans/2026-03-29-service-layer-phase1.md +0 -0
  134. {blq_cli-1.0.0 → blq_cli-1.0.2}/docs/superpowers/plans/2026-03-29-strace-profiling.md +0 -0
  135. {blq_cli-1.0.0 → blq_cli-1.0.2}/docs/superpowers/specs/2026-03-22-extension-system-design.md +0 -0
  136. {blq_cli-1.0.0 → blq_cli-1.0.2}/docs/superpowers/specs/2026-03-29-unified-service-layer-design.md +0 -0
  137. {blq_cli-1.0.0 → blq_cli-1.0.2}/experiments/agent-build-test.md +0 -0
  138. {blq_cli-1.0.0 → blq_cli-1.0.2}/mkdocs.yml +0 -0
  139. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq/__main__.py +0 -0
  140. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq/bird_schema.sql +0 -0
  141. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq/commands/README.md +0 -0
  142. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq/commands/__init__.py +0 -0
  143. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq/commands/ci_cmd.py +0 -0
  144. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq/commands/clean_cmd.py +0 -0
  145. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq/commands/config_cmd.py +0 -0
  146. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq/commands/hooks_cmd.py +0 -0
  147. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq/commands/hooks_gen.py +0 -0
  148. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq/commands/management.py +0 -0
  149. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq/commands/management_cmd.py +0 -0
  150. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq/commands/migrate.py +0 -0
  151. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq/commands/query_cmd.py +0 -0
  152. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq/commands/record_cmd.py +0 -0
  153. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq/commands/registry.py +0 -0
  154. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq/commands/report_cmd.py +0 -0
  155. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq/commands/serve_cmd.py +0 -0
  156. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq/commands/watch_cmd.py +0 -0
  157. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq/config_format.py +0 -0
  158. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq/git.py +0 -0
  159. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq/github.py +0 -0
  160. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq/locks.py +0 -0
  161. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq/output.py +0 -0
  162. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq/schema.sql +0 -0
  163. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq/services/execution.py +0 -0
  164. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq/services/inspect.py +0 -0
  165. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq/templates/drone.yml.j2 +0 -0
  166. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq/templates/git_hook.sh.j2 +0 -0
  167. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq/templates/github_workflow.yml.j2 +0 -0
  168. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq/templates/gitlab_ci.yml.j2 +0 -0
  169. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq/templates/hook_script.sh.j2 +0 -0
  170. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq/user_config.py +0 -0
  171. {blq_cli-1.0.0 → blq_cli-1.0.2}/src/blq_sandbox_bwrap/args.py +0 -0
  172. {blq_cli-1.0.0 → blq_cli-1.0.2}/tests/__init__.py +0 -0
  173. {blq_cli-1.0.0 → blq_cli-1.0.2}/tests/conftest.py +0 -0
  174. {blq_cli-1.0.0 → blq_cli-1.0.2}/tests/sql/test_duck_hunt_v2_migration.sql +0 -0
  175. {blq_cli-1.0.0 → blq_cli-1.0.2}/tests/test_attempts_outcomes.py +0 -0
  176. {blq_cli-1.0.0 → blq_cli-1.0.2}/tests/test_auto_init.py +0 -0
  177. {blq_cli-1.0.0 → blq_cli-1.0.2}/tests/test_autoprune.py +0 -0
  178. {blq_cli-1.0.0 → blq_cli-1.0.2}/tests/test_ci.py +0 -0
  179. {blq_cli-1.0.0 → blq_cli-1.0.2}/tests/test_ci_generators.py +0 -0
  180. {blq_cli-1.0.0 → blq_cli-1.0.2}/tests/test_command_args.py +0 -0
  181. {blq_cli-1.0.0 → blq_cli-1.0.2}/tests/test_core.py +0 -0
  182. {blq_cli-1.0.0 → blq_cli-1.0.2}/tests/test_exit_code_reason.py +0 -0
  183. {blq_cli-1.0.0 → blq_cli-1.0.2}/tests/test_git.py +0 -0
  184. {blq_cli-1.0.0 → blq_cli-1.0.2}/tests/test_hooks.py +0 -0
  185. {blq_cli-1.0.0 → blq_cli-1.0.2}/tests/test_hooks_gen.py +0 -0
  186. {blq_cli-1.0.0 → blq_cli-1.0.2}/tests/test_inspect.py +0 -0
  187. {blq_cli-1.0.0 → blq_cli-1.0.2}/tests/test_json_null_filter.py +0 -0
  188. {blq_cli-1.0.0 → blq_cli-1.0.2}/tests/test_mcp_ci_tools.py +0 -0
  189. {blq_cli-1.0.0 → blq_cli-1.0.2}/tests/test_mcp_merge.py +0 -0
  190. {blq_cli-1.0.0 → blq_cli-1.0.2}/tests/test_mcp_server.py +0 -0
  191. {blq_cli-1.0.0 → blq_cli-1.0.2}/tests/test_phase1_structured_output.py +0 -0
  192. {blq_cli-1.0.0 → blq_cli-1.0.2}/tests/test_phase2_command_registry.py +0 -0
  193. {blq_cli-1.0.0 → blq_cli-1.0.2}/tests/test_query_api.py +0 -0
  194. {blq_cli-1.0.0 → blq_cli-1.0.2}/tests/test_query_filter.py +0 -0
  195. {blq_cli-1.0.0 → blq_cli-1.0.2}/tests/test_record_invocation.py +0 -0
  196. {blq_cli-1.0.0 → blq_cli-1.0.2}/tests/test_report.py +0 -0
  197. {blq_cli-1.0.0 → blq_cli-1.0.2}/tests/test_services_refs.py +0 -0
  198. {blq_cli-1.0.0 → blq_cli-1.0.2}/tests/test_storage.py +0 -0
  199. {blq_cli-1.0.0 → blq_cli-1.0.2}/tests/test_storage_prune.py +0 -0
  200. {blq_cli-1.0.0 → blq_cli-1.0.2}/tests/test_user_config.py +0 -0
  201. {blq_cli-1.0.0 → blq_cli-1.0.2}/tests/test_watch.py +0 -0
@@ -0,0 +1,6 @@
1
+ # Changelog
2
+
3
+ ## v1.0.2
4
+
5
+ ### Bug fixes
6
+ - **Self-heal DBs stuck without `extension_data`.** The 2.3->2.4 migration renamed `sandbox` -> `extension_data` via `RENAME COLUMN`, which DuckDB blocks when non-view dependencies (FKs/constraints) exist on `attempts`/`invocations` — even after dropping views. The rename failed silently but the schema version advanced anyway, leaving DBs permanently stuck without `extension_data` and crashing every `write_attempt` ("Table attempts does not have a column extension_data"). `_ensure_schema` now self-heals version-independently: it gates on a missing column, adds `extension_data` via `ADD COLUMN` (never blocked), copies any existing `sandbox` data across (wrapped as `{"sandbox": ...}`, no data loss), and re-applies the idempotent schema to recreate views. Healthy/fresh DBs stay a fast no-op. Repairs on next connect (the CLI heals on the next `blq run`; a running MCP server heals after restart).
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: blq-cli
3
- Version: 1.0.0
4
- Summary: Buidl Log Query - capture and query build/test logs with DuckDB
5
- Project-URL: Homepage, https://blq-cli.readthedocs.com/
3
+ Version: 1.0.2
4
+ Summary: Build Log Query - capture and query build/test logs with DuckDB
5
+ Project-URL: Homepage, https://blq-cli.readthedocs.io/
6
6
  Project-URL: Repository, https://github.com/teaguesterling/blq-cli
7
7
  Project-URL: Issues, https://github.com/teaguesterling/blq-cli/issues
8
8
  Author-email: Teague Sterling <teaguesterling@gmail.com>
@@ -0,0 +1,105 @@
1
+ # Exploration: SQLite as BIRD OLTP Backend
2
+
3
+ *A prompt for exploring SQLite as the transactional write layer for BIRD, with DuckDB for analytics.*
4
+
5
+ ## Context
6
+
7
+ The BIRD spec v5 (at `~/Projects/magic/docs/spec/v5/bird-v5.md`) defines a layered architecture:
8
+ - **D-layers** (D0-D4): logical query interface (attempts, outcomes, events, etc.)
9
+ - **S-layers**: storage implementation (currently parquet files or DuckDB tables)
10
+
11
+ blq currently uses DuckDB tables for both OLTP (writes) and OLAP (reads), with a retry/backoff pattern to handle DuckDB's single-writer limitation.
12
+
13
+ Kibitzer (`~/Projects/kibitzer`) uses JSON state files for high-frequency writes (every tool call) and wants to share data with blq.
14
+
15
+ ## The Proposal
16
+
17
+ Add SQLite as an S-layer option for BIRD — specifically for the OLTP (write) path:
18
+
19
+ ```
20
+ Tool hooks (kibitzer) → .bird/bird.sqlite ← blq run
21
+ MCP servers → (OLTP writes) ← blq exec
22
+ ↓
23
+ DuckDB ATTACH
24
+ ↓
25
+ .bird/blq.duckdb
26
+ (OLAP reads)
27
+ ↓
28
+ blq events / blq sql / MCP queries
29
+ ```
30
+
31
+ ### Why SQLite for writes
32
+ - WAL mode handles concurrent writes gracefully (vs DuckDB single-writer)
33
+ - Designed for embedded OLTP (vs DuckDB designed for OLAP)
34
+ - Kibitzer writes on *every tool call* — needs fast, concurrent inserts
35
+ - DuckDB's `sqlite` extension can ATTACH and read SQLite tables natively
36
+ - Eliminates the retry/backoff dance in blq's Window 1/Window 2 pattern
37
+
38
+ ### Why keep DuckDB for reads
39
+ - OLAP queries (aggregations, joins, window functions) are DuckDB's strength
40
+ - Macros like `blq_load_events()` use DuckDB-specific SQL
41
+ - duck_hunt parsing runs inside DuckDB
42
+ - DuckDB's columnar storage is better for analytics over large datasets
43
+
44
+ ## Questions to Explore
45
+
46
+ ### 1. BIRD Spec Changes
47
+
48
+ Read the full BIRD v5 spec at `~/Projects/magic/docs/spec/v5/bird-v5.md`.
49
+
50
+ - How does the S-layer abstraction work? Can SQLite be added as an S-layer without changing D-layers?
51
+ - Should SQLite replace parquet as the default, or be a third option?
52
+ - How does DuckDB's `ATTACH 'bird.sqlite' AS bird_sqlite (TYPE SQLITE)` interact with BIRD's schema ownership model?
53
+ - Should the `invocations` view join across the DuckDB/SQLite boundary, or should we materialize from SQLite into DuckDB periodically?
54
+
55
+ ### 2. Write Path
56
+
57
+ - Which tables need to be in SQLite? (attempts, outcomes, events, outputs, sessions)
58
+ - Should blob storage stay on the filesystem (content-addressed .bin files)?
59
+ - How to handle the metadata MAP column — SQLite doesn't have MAP type (use JSON instead?)
60
+ - Schema for SQLite tables — mirror the DuckDB schema with type adaptations?
61
+
62
+ ### 3. Read Path
63
+
64
+ Two approaches:
65
+ - **Live ATTACH**: DuckDB ATTACHes SQLite file, views read directly from it. Simple but may have locking issues.
66
+ - **Sync/materialize**: Periodic copy from SQLite → DuckDB tables. More complex but fully decoupled.
67
+
68
+ ### 4. Kibitzer Integration
69
+
70
+ - Should kibitzer write to the same `.bird/bird.sqlite` file?
71
+ - What tables does kibitzer need? (tool_calls, mode_changes, patterns, suggestions)
72
+ - Should these be BIRD D-layer tables or kibitzer-specific tables in a `kibitzer` schema?
73
+ - How does the coach's pattern detection query blq's events alongside kibitzer's tool_call history?
74
+
75
+ ### 5. Migration
76
+
77
+ - How to migrate existing `.bird/blq.duckdb` data to the new SQLite + DuckDB split?
78
+ - Should `blq.duckdb` still exist (for macros, views, materialized analytics)?
79
+ - Backward compatibility with v1.0 databases?
80
+
81
+ ### 6. Performance
82
+
83
+ - SQLite WAL mode write performance for kibitzer's volume (~100 writes/min during active agent session)
84
+ - DuckDB ATTACH read performance vs native table performance
85
+ - Memory impact of keeping both connections open
86
+
87
+ ## Research Steps
88
+
89
+ 1. Read BIRD v5 spec in full — understand S-layer abstraction
90
+ 2. Prototype: create a SQLite DB with BIRD tables, ATTACH from DuckDB, run blq macros
91
+ 3. Benchmark: write throughput (SQLite WAL vs DuckDB retry pattern)
92
+ 4. Benchmark: read performance (ATTACH vs native tables)
93
+ 5. Design the schema mapping (DuckDB types → SQLite types, especially MAP → JSON)
94
+ 6. Prototype kibitzer writing tool_calls to the same SQLite DB
95
+ 7. Test cross-database queries (kibitzer tool_calls JOIN blq events)
96
+
97
+ ## References
98
+
99
+ - BIRD v5 spec: `~/Projects/magic/docs/spec/v5/bird-v5.md`
100
+ - BIRD v4 spec: `~/Projects/magic/docs/bird_spec.md`
101
+ - blq schema: `src/blq/bird_schema.sql`
102
+ - blq BirdStore: `src/blq/bird.py` (Window 1/2 pattern)
103
+ - kibitzer state: `~/Projects/kibitzer/src/kibitzer/state.py`
104
+ - kibitzer architecture: `~/Projects/kibitzer/docs/architecture.md`
105
+ - DuckDB SQLite extension: https://duckdb.org/docs/extensions/sqlite
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "blq-cli"
7
- version = "1.0.0"
8
- description = "Buidl Log Query - capture and query build/test logs with DuckDB"
7
+ version = "1.0.2"
8
+ description = "Build Log Query - capture and query build/test logs with DuckDB"
9
9
  readme = "README.md"
10
10
  license = "MIT"
11
11
  requires-python = ">=3.10"
@@ -58,7 +58,7 @@ mcp = [
58
58
  blq = "blq.cli:main"
59
59
 
60
60
  [project.urls]
61
- Homepage = "https://blq-cli.readthedocs.com/"
61
+ Homepage = "https://blq-cli.readthedocs.io/"
62
62
  Repository = "https://github.com/teaguesterling/blq-cli"
63
63
  Issues = "https://github.com/teaguesterling/blq-cli/issues"
64
64
 
@@ -14,7 +14,7 @@ Example usage:
14
14
  events = LogQuery.from_file("build.log").filter(severity="error").df()
15
15
  """
16
16
 
17
- __version__ = "0.1.0"
17
+ __version__ = "1.0.1"
18
18
 
19
19
  from blq.query import LogQuery, LogQueryGrouped, LogStore
20
20
 
@@ -420,13 +420,26 @@ class BirdStore:
420
420
  ).fetchone()
421
421
  if result:
422
422
  current_version = result[0]
423
- # Apply any pending migrations
424
- cls._apply_migrations(conn, current_version)
425
- return
426
423
  except duckdb.Error:
427
424
  pass # Table doesn't exist, need to create
428
425
 
429
- # Load schema from SQL file
426
+ if current_version is not None and not force:
427
+ # Existing DB. Only touch it when there's pending migration work or a
428
+ # column a past (possibly silently-failed) migration never added;
429
+ # otherwise this is a fast no-op.
430
+ if not cls._needs_repair(conn, current_version):
431
+ return
432
+ # DuckDB blocks column ALTER/RENAME while views depend on the table
433
+ # (this is what left old DBs stuck without extension_data). Drop all
434
+ # views first so migrations + the reconcile can alter the base tables;
435
+ # re-applying the schema below recreates every view.
436
+ cls._drop_all_views(conn)
437
+ cls._apply_migrations(conn, current_version)
438
+ cls._reconcile_schema(conn)
439
+
440
+ # Load schema from SQL file (idempotent: CREATE TABLE IF NOT EXISTS,
441
+ # INSERT OR IGNORE, CREATE OR REPLACE VIEW — safe to re-apply, and it
442
+ # recreates any views dropped above).
430
443
  schema_path = Path(__file__).parent / "bird_schema.sql"
431
444
  if schema_path.exists():
432
445
  schema_sql = schema_path.read_text()
@@ -504,6 +517,92 @@ class BirdStore:
504
517
 
505
518
  return statements
506
519
 
520
+ @classmethod
521
+ def _needs_repair(cls, conn: duckdb.DuckDBPyConnection, current_version: str) -> bool:
522
+ """True if the DB needs migration/repair — pending version bump OR a
523
+ required column a past (possibly silently-failed) migration never added.
524
+
525
+ Keeps the common path a fast no-op: a fully current DB returns False, so
526
+ _ensure_schema doesn't drop/recreate views on every connect.
527
+ """
528
+ try:
529
+ parts = [int(p) for p in current_version.split(".")]
530
+ major, minor = parts[0], parts[1] if len(parts) > 1 else 0
531
+ if (major, minor) < (3, 0):
532
+ return True
533
+ except (ValueError, IndexError):
534
+ return True # unparseable version — reconcile to be safe
535
+ # Self-heal for migrations that failed silently on dependent views and
536
+ # then let the version advance past themselves (leaving no column).
537
+ for table in ("attempts", "invocations"):
538
+ try:
539
+ has = conn.execute(
540
+ "SELECT 1 FROM information_schema.columns "
541
+ f"WHERE table_name = '{table}' AND column_name = 'extension_data'"
542
+ ).fetchone()
543
+ if not has:
544
+ return True
545
+ except duckdb.Error:
546
+ return True
547
+ return False
548
+
549
+ @staticmethod
550
+ def _drop_all_views(conn: duckdb.DuckDBPyConnection) -> None:
551
+ """Drop all views so base-table column ALTER/RENAME isn't blocked by
552
+ dependents. The schema is re-applied afterward (CREATE OR REPLACE VIEW)
553
+ to recreate them."""
554
+ try:
555
+ views = conn.execute(
556
+ "SELECT table_name FROM information_schema.tables "
557
+ "WHERE table_type = 'VIEW'"
558
+ ).fetchall()
559
+ except duckdb.Error:
560
+ return
561
+ for (name,) in views:
562
+ try:
563
+ conn.execute(f'DROP VIEW IF EXISTS "{name}" CASCADE')
564
+ except duckdb.Error:
565
+ pass
566
+
567
+ @classmethod
568
+ def _reconcile_schema(cls, conn: duckdb.DuckDBPyConnection) -> None:
569
+ """Version-independent self-heal: ensure `extension_data` exists on
570
+ attempts and invocations even if a past migration failed silently (blocked
571
+ by dependent views) and the schema version advanced past it. Views must
572
+ already be dropped by the caller so the ALTER/RENAME can proceed.
573
+
574
+ Uses ADD COLUMN + data copy, NOT rename: RENAME COLUMN on these tables is
575
+ blocked by non-view dependencies (FKs/constraints) that persist even after
576
+ dropping views, whereas ADD COLUMN is never blocked. Existing `sandbox`
577
+ data (from the 2.3 migration) is copied into the new column wrapped as
578
+ {"sandbox": ...}; the now-redundant `sandbox` column is left in place
579
+ (harmless, and DROP COLUMN hits the same dependency wall).
580
+ """
581
+ for table in ("attempts", "invocations"):
582
+ try:
583
+ cols = {
584
+ r[0]
585
+ for r in conn.execute(
586
+ "SELECT column_name FROM information_schema.columns "
587
+ f"WHERE table_name = '{table}'"
588
+ ).fetchall()
589
+ }
590
+ if "extension_data" in cols:
591
+ continue
592
+ conn.execute(f"ALTER TABLE {table} ADD COLUMN extension_data JSON")
593
+ if "sandbox" in cols:
594
+ conn.execute(
595
+ f"UPDATE {table} SET extension_data = "
596
+ f"json_object('sandbox', sandbox) WHERE sandbox IS NOT NULL"
597
+ )
598
+ logger.info(
599
+ f"Repaired {table}: added extension_data (copied sandbox data)"
600
+ )
601
+ else:
602
+ logger.info(f"Repaired {table}: added extension_data")
603
+ except duckdb.Error as e:
604
+ logger.warning(f"Schema reconcile warning ({table}): {e}")
605
+
507
606
  @classmethod
508
607
  def _apply_migrations(cls, conn: duckdb.DuckDBPyConnection, current_version: str) -> None:
509
608
  """Apply schema migrations from current version to latest.
@@ -557,42 +656,15 @@ class BirdStore:
557
656
  # Update schema version
558
657
  conn.execute("UPDATE blq_metadata SET value = '2.3.0' WHERE key = 'schema_version'")
559
658
 
560
- # Migration: 2.3.0 -> 2.4.0 (rename sandbox to extension_data)
659
+ # Migration: 2.3.0 -> 2.4.0 (sandbox -> extension_data).
660
+ # RENAME COLUMN is blocked by non-view dependencies on these tables, so
661
+ # this is done as ADD + copy via _reconcile_schema (which is idempotent
662
+ # and always succeeds), rather than the RENAME that silently failed and
663
+ # left old DBs stuck without extension_data.
561
664
  if (major, minor) < (2, 4):
562
- migration_ok = True
563
- for table in ("attempts", "invocations"):
564
- try:
565
- # Check if old column exists
566
- result = conn.execute(
567
- "SELECT column_name FROM information_schema.columns "
568
- f"WHERE table_name = '{table}' AND column_name = 'sandbox'"
569
- ).fetchone()
570
- if result:
571
- conn.execute(f"ALTER TABLE {table} RENAME COLUMN sandbox TO extension_data")
572
- # Wrap existing sandbox data in {"sandbox": ...} envelope
573
- conn.execute(
574
- f"UPDATE {table} SET extension_data = "
575
- f"json_object('sandbox', extension_data) "
576
- f"WHERE extension_data IS NOT NULL"
577
- )
578
- logger.info(f"Migration: Renamed sandbox to extension_data in {table}")
579
- migrations_applied = True
580
- else:
581
- # Check if new column exists, add if not
582
- result = conn.execute(
583
- "SELECT column_name FROM information_schema.columns "
584
- f"WHERE table_name = '{table}' AND column_name = 'extension_data'"
585
- ).fetchone()
586
- if not result:
587
- conn.execute(f"ALTER TABLE {table} ADD COLUMN extension_data JSON")
588
- logger.info(f"Migration: Added extension_data column to {table}")
589
- migrations_applied = True
590
- except duckdb.Error as e:
591
- logger.warning(f"Migration warning: {e}")
592
- migration_ok = False
593
-
594
- if migration_ok:
595
- conn.execute("UPDATE blq_metadata SET value = '2.4.0' WHERE key = 'schema_version'")
665
+ cls._reconcile_schema(conn)
666
+ migrations_applied = True
667
+ conn.execute("UPDATE blq_metadata SET value = '2.4.0' WHERE key = 'schema_version'")
596
668
 
597
669
  # Migration: 2.4.0 -> 3.0.0 (directory rename .lq -> .bird, no table changes)
598
670
  if (major, minor) < (3, 0):
@@ -1193,7 +1265,7 @@ class BirdStore:
1193
1265
  )
1194
1266
 
1195
1267
  # Clean up any stale lock files left by orphaned processes
1196
- locks_dir = self.lq_dir / "locks"
1268
+ locks_dir = self._lq_dir / "locks"
1197
1269
  cleanup_stale_locks(locks_dir)
1198
1270
 
1199
1271
  return orphaned_ids
@@ -1330,9 +1330,7 @@ def main() -> None:
1330
1330
  cmd_sandbox_tighten,
1331
1331
  )
1332
1332
 
1333
- p_sandbox = subparsers.add_parser(
1334
- "sandbox", help="Manage sandbox specifications"
1335
- )
1333
+ p_sandbox = subparsers.add_parser("sandbox", help="Manage sandbox specifications")
1336
1334
  sandbox_subparsers = p_sandbox.add_subparsers(
1337
1335
  dest="sandbox_command", help="Sandbox subcommands"
1338
1336
  )
@@ -1358,9 +1356,7 @@ def main() -> None:
1358
1356
  p_sandbox_suggest.set_defaults(func=cmd_sandbox_suggest)
1359
1357
 
1360
1358
  # sandbox profile
1361
- p_sandbox_profile = sandbox_subparsers.add_parser(
1362
- "profile", help="Profile command with strace"
1363
- )
1359
+ p_sandbox_profile = sandbox_subparsers.add_parser("profile", help="Profile command with strace")
1364
1360
  p_sandbox_profile.add_argument("command", help="Command name to profile")
1365
1361
  p_sandbox_profile.add_argument(
1366
1362
  "--json", "-j", action="store_true", help="Output raw profile as JSON"
@@ -835,8 +835,7 @@ class BlqConfig:
835
835
  legacy_path.rename(bird_path)
836
836
  logger.info(f"Migrated {legacy_path} → {bird_path}")
837
837
  print(
838
- f" Migrated {LEGACY_DIR}/ → {BIRD_DIR}/ "
839
- f"(legacy directory renamed)",
838
+ f" Migrated {LEGACY_DIR}/ → {BIRD_DIR}/ (legacy directory renamed)",
840
839
  file=sys.stderr,
841
840
  )
842
841
  return cls.load(bird_path)
@@ -394,7 +394,10 @@ def _format_context_with_read_lines(
394
394
  conn.execute("""
395
395
  CREATE OR REPLACE MACRO blq_read_lines(content, lines_spec, marks := []) AS TABLE
396
396
  WITH mark_ranges AS (
397
- SELECT unnest(marks::STRUCT(start INT, "end" INT, mark VARCHAR)[], recursive := true)
397
+ SELECT unnest(
398
+ marks::STRUCT(start INT, "end" INT, mark VARCHAR)[],
399
+ recursive := true
400
+ )
398
401
  )
399
402
  SELECT
400
403
  l.line_number,
@@ -458,18 +458,20 @@ def _execute_with_live_output(
458
458
  dims.append(f"{key}={sandbox[key]}")
459
459
  spec_summary = ", ".join(dims) if dims else "custom"
460
460
 
461
- events.append({
462
- "severity": "info",
463
- "message": (
464
- f"Command failed in sandbox ({spec_summary},"
465
- f" grade_w={grade_w}, effects_ceiling={ceiling})"
466
- ),
467
- "code": f"sandbox_exit_{exit_code}",
468
- "fingerprint": hashlib.blake2b(
469
- f"sandbox:{grade_w}:{ceiling}:{exit_code}".encode(),
470
- digest_size=8,
471
- ).hexdigest(),
472
- })
461
+ events.append(
462
+ {
463
+ "severity": "info",
464
+ "message": (
465
+ f"Command failed in sandbox ({spec_summary},"
466
+ f" grade_w={grade_w}, effects_ceiling={ceiling})"
467
+ ),
468
+ "code": f"sandbox_exit_{exit_code}",
469
+ "fingerprint": hashlib.blake2b(
470
+ f"sandbox:{grade_w}:{ceiling}:{exit_code}".encode(),
471
+ digest_size=8,
472
+ ).hexdigest(),
473
+ }
474
+ )
473
475
 
474
476
  # Detect specific sandbox violations in output
475
477
  if cmd_spec.extension_data.get("sandbox") and exit_code != 0 and output:
@@ -478,17 +480,19 @@ def _execute_with_live_output(
478
480
  sandbox = cmd_spec.extension_data["sandbox"]
479
481
  violations = detect_violations(output, sandbox)
480
482
  for v in violations:
481
- events.append({
482
- "severity": "info",
483
- "message": f"Sandbox {v.dimension} violation: {v.pattern}",
484
- "code": f"sandbox_violation_{v.dimension}",
485
- "fingerprint": hashlib.blake2b(
486
- f"sandbox_violation:{v.dimension}:{v.pattern}".encode(),
487
- digest_size=8,
488
- ).hexdigest(),
489
- "log_line_start": v.line_number,
490
- "log_line_end": v.line_number,
491
- })
483
+ events.append(
484
+ {
485
+ "severity": "info",
486
+ "message": f"Sandbox {v.dimension} violation: {v.pattern}",
487
+ "code": f"sandbox_violation_{v.dimension}",
488
+ "fingerprint": hashlib.blake2b(
489
+ f"sandbox_violation:{v.dimension}:{v.pattern}".encode(),
490
+ digest_size=8,
491
+ ).hexdigest(),
492
+ "log_line_start": v.line_number,
493
+ "log_line_end": v.line_number,
494
+ }
495
+ )
492
496
 
493
497
  # =========================================================================
494
498
  # Window 2: Post-execution DB access (minimal lock time)
@@ -536,9 +540,7 @@ def _execute_with_live_output(
536
540
 
537
541
  # Update attempt with enriched extension_data (grades, metrics)
538
542
  if cmd_spec.extension_data:
539
- store.update_attempt_extension_data(
540
- attempt_id, cmd_spec.extension_data
541
- )
543
+ store.update_attempt_extension_data(attempt_id, cmd_spec.extension_data)
542
544
 
543
545
  # Write events
544
546
  store.write_events(
@@ -508,8 +508,16 @@ def _add_to_gitignore(cwd: Path) -> bool:
508
508
  for line in lines:
509
509
  stripped = line.strip()
510
510
  if stripped in (
511
- ".bird", ".bird/", "/.bird", "/.bird/", ".bird/*",
512
- ".lq", ".lq/", "/.lq", "/.lq/", ".lq/*",
511
+ ".bird",
512
+ ".bird/",
513
+ "/.bird",
514
+ "/.bird/",
515
+ ".bird/*",
516
+ ".lq",
517
+ ".lq/",
518
+ "/.lq",
519
+ "/.lq/",
520
+ ".lq/*",
513
521
  ):
514
522
  # Already has some form of ignore entry
515
523
  # Check if it's the new pattern with exceptions
@@ -184,8 +184,69 @@ def cmd_mcp_install(args: argparse.Namespace) -> None:
184
184
  print(" Or set hooks.auto_claude_code = true in user config")
185
185
 
186
186
 
187
+ def _apply_mcp_thread_limits() -> None:
188
+ """Cap DuckDB and asyncio thread pools for the MCP server process.
189
+
190
+ The MCP server is long-lived and mostly idle, so it has no business
191
+ holding DuckDB's default per-CPU threadpool (~36 threads on a 36-core
192
+ host) or asyncio's default ThreadPoolExecutor (`min(32, cpu_count + 4)`).
193
+ Without these caps each `blq mcp serve` process holds ~73 idle threads;
194
+ with `N` Claude sessions that compounds to `N * 73`.
195
+
196
+ Both caps are configurable via env vars (sized for "responsive enough
197
+ for one capture/query at a time" rather than parallel throughput):
198
+ BLQ_MCP_DUCKDB_THREADS — default 2
199
+ BLQ_MCP_ASYNC_WORKERS — default 4
200
+ """
201
+ import asyncio
202
+ import os
203
+ from concurrent.futures import ThreadPoolExecutor
204
+
205
+ duckdb_threads = max(1, int(os.environ.get("BLQ_MCP_DUCKDB_THREADS", "2")))
206
+ async_workers = max(1, int(os.environ.get("BLQ_MCP_ASYNC_WORKERS", "4")))
207
+
208
+ # 1) DuckDB: wrap duckdb.connect so every connection lowers its thread
209
+ # count immediately after open. There are 24+ duckdb.connect callsites
210
+ # across blq; patching the entry point covers them all without
211
+ # threading config through every caller.
212
+ import duckdb
213
+
214
+ _original_connect = duckdb.connect
215
+
216
+ def _capped_connect(*args, **kwargs): # type: ignore[no-untyped-def]
217
+ conn = _original_connect(*args, **kwargs)
218
+ try:
219
+ conn.execute(f"PRAGMA threads={duckdb_threads}")
220
+ except Exception:
221
+ # Read-only connections or some attachment modes may refuse PRAGMA;
222
+ # leave those at the default rather than crash startup.
223
+ pass
224
+ return conn
225
+
226
+ duckdb.connect = _capped_connect # type: ignore[assignment]
227
+
228
+ # 2) asyncio: install a bounded default executor BEFORE fastmcp starts
229
+ # its event loop. fastmcp uses the running loop's default executor
230
+ # for blocking calls (subprocess waits, file I/O). 4 workers is
231
+ # plenty for the MCP server's "one tool call at a time" workload.
232
+ try:
233
+ loop = asyncio.new_event_loop()
234
+ loop.set_default_executor(
235
+ ThreadPoolExecutor(max_workers=async_workers, thread_name_prefix="blq-async"),
236
+ )
237
+ asyncio.set_event_loop(loop)
238
+ except Exception:
239
+ # If asyncio loop policy disallows replacing the loop here, fastmcp
240
+ # will create its own and we keep the DuckDB cap which is the bigger
241
+ # win anyway.
242
+ pass
243
+
244
+
187
245
  def cmd_mcp_serve(args: argparse.Namespace) -> None:
188
246
  """Start the MCP server."""
247
+ # Cap thread pools BEFORE importing serve (which loads fastmcp/duckdb).
248
+ _apply_mcp_thread_limits()
249
+
189
250
  from blq.serve import serve
190
251
  from blq.user_config import UserConfig
191
252
 
@@ -145,11 +145,11 @@ def cmd_sandbox_suggest(args: Any) -> None:
145
145
  print(f"Error querying metrics: {e}", file=sys.stderr)
146
146
  sys.exit(1)
147
147
 
148
- run_count = result[0] if result else 0
149
- if run_count == 0:
148
+ if not result or result[0] == 0:
150
149
  print(f"No runs found for '{cmd_name}'. Run it a few times first.")
151
150
  return
152
151
 
152
+ run_count = result[0]
153
153
  max_memory = result[1]
154
154
  max_cpu_usec = result[2]
155
155
  max_duration_ms = result[3]
@@ -327,8 +327,8 @@ def cmd_sandbox_tighten(args: Any) -> None:
327
327
  print(f"Error querying metrics: {e}", file=sys.stderr)
328
328
  sys.exit(1)
329
329
 
330
- run_count = result[0] if result else 0
331
- if run_count < 3:
330
+ if not result or result[0] < 3:
331
+ run_count = result[0] if result else 0
332
332
  print(
333
333
  f"Insufficient data: only {run_count} run(s) found for '{cmd_name}'. "
334
334
  "At least 3 runs are required for reliable tightening.",
@@ -357,7 +357,7 @@ def cmd_sandbox_tighten(args: Any) -> None:
357
357
  return
358
358
 
359
359
  # Show diff
360
- from blq_sandbox.spec import format_size, format_duration
360
+ from blq_sandbox.spec import format_duration, format_size
361
361
 
362
362
  print(f"Tightening sandbox spec for '{cmd_name}' (based on {run_count} runs):")
363
363
  print()
@@ -69,7 +69,10 @@ def cmd_sync(args: argparse.Namespace) -> None:
69
69
  # Validate project info exists
70
70
  if not config.namespace or not config.project:
71
71
  print("Error: Project namespace/project not configured.", file=sys.stderr)
72
- print("Run 'blq init --namespace X --project Y' or set in .bird/config.toml", file=sys.stderr)
72
+ print(
73
+ "Run 'blq init --namespace X --project Y' or set in .bird/config.toml",
74
+ file=sys.stderr,
75
+ )
73
76
  sys.exit(1)
74
77
 
75
78
  # Get sync parameters
@@ -3,6 +3,7 @@
3
3
  Defines the structured execution pipeline: CommandSpec flows through
4
4
  Extension.prepare() → Executor.execute() → Collector.collect().
5
5
  """
6
+
6
7
  from __future__ import annotations
7
8
 
8
9
  from dataclasses import dataclass, field
@@ -5,6 +5,7 @@ Provides:
5
5
  - RunContext: lazy, DB-backed access to a stored run
6
6
  - Annotator protocol + dispatch: plugin discovery and execution
7
7
  """
8
+
8
9
  from __future__ import annotations
9
10
 
10
11
  import json
@@ -32,9 +33,7 @@ class Annotation:
32
33
 
33
34
  def __post_init__(self) -> None:
34
35
  if self.display not in VALID_DISPLAYS:
35
- raise ValueError(
36
- f"display must be one of {VALID_DISPLAYS}, got {self.display!r}"
37
- )
36
+ raise ValueError(f"display must be one of {VALID_DISPLAYS}, got {self.display!r}")
38
37
 
39
38
  def to_dict(self) -> dict[str, Any]:
40
39
  return {
@@ -92,10 +91,7 @@ class RunContext:
92
91
  [self._invocation_id],
93
92
  ).fetchall()
94
93
  columns = [
95
- desc[0]
96
- for desc in self._conn.execute(
97
- "SELECT * FROM events LIMIT 0"
98
- ).description
94
+ desc[0] for desc in self._conn.execute("SELECT * FROM events LIMIT 0").description
99
95
  ]
100
96
  self._events = [dict(zip(columns, row)) for row in rows]
101
97
  return self._events
@@ -124,7 +120,8 @@ class RunContext:
124
120
 
125
121
  @property
126
122
  def extension_data(self) -> dict[str, Any]:
127
- return self.metadata["extension_data"]
123
+ data: dict[str, Any] = self.metadata["extension_data"]
124
+ return data
128
125
 
129
126
  def _load_outcome(self) -> None:
130
127
  if not self._outcome_loaded: