argus-code 0.4.0__tar.gz → 0.4.1__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 (182) hide show
  1. {argus_code-0.4.0 → argus_code-0.4.1}/PKG-INFO +1 -1
  2. {argus_code-0.4.0 → argus_code-0.4.1}/pyproject.toml +1 -1
  3. {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/__init__.py +1 -1
  4. {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/server/app.py +42 -3
  5. {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/store/db.py +81 -19
  6. {argus_code-0.4.0 → argus_code-0.4.1}/tests/server/test_server.py +48 -1
  7. {argus_code-0.4.0 → argus_code-0.4.1}/tests/store/test_db.py +36 -0
  8. {argus_code-0.4.0 → argus_code-0.4.1}/uv.lock +983 -983
  9. {argus_code-0.4.0 → argus_code-0.4.1}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  10. {argus_code-0.4.0 → argus_code-0.4.1}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  11. {argus_code-0.4.0 → argus_code-0.4.1}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  12. {argus_code-0.4.0 → argus_code-0.4.1}/.github/pull_request_template.md +0 -0
  13. {argus_code-0.4.0 → argus_code-0.4.1}/.gitignore +0 -0
  14. {argus_code-0.4.0 → argus_code-0.4.1}/.npmrc +0 -0
  15. {argus_code-0.4.0 → argus_code-0.4.1}/ARCHITECTURE.md +0 -0
  16. {argus_code-0.4.0 → argus_code-0.4.1}/CONTRIBUTING.md +0 -0
  17. {argus_code-0.4.0 → argus_code-0.4.1}/LICENSE +0 -0
  18. {argus_code-0.4.0 → argus_code-0.4.1}/NOTICE +0 -0
  19. {argus_code-0.4.0 → argus_code-0.4.1}/PRD.md +0 -0
  20. {argus_code-0.4.0 → argus_code-0.4.1}/README.md +0 -0
  21. {argus_code-0.4.0 → argus_code-0.4.1}/SECURITY.md +0 -0
  22. {argus_code-0.4.0 → argus_code-0.4.1}/TESTING.md +0 -0
  23. {argus_code-0.4.0 → argus_code-0.4.1}/dashboard/.astro/content-assets.mjs +0 -0
  24. {argus_code-0.4.0 → argus_code-0.4.1}/dashboard/.astro/content-modules.mjs +0 -0
  25. {argus_code-0.4.0 → argus_code-0.4.1}/dashboard/.astro/content.d.ts +0 -0
  26. {argus_code-0.4.0 → argus_code-0.4.1}/dashboard/.astro/types.d.ts +0 -0
  27. {argus_code-0.4.0 → argus_code-0.4.1}/dashboard/astro.config.mjs +0 -0
  28. {argus_code-0.4.0 → argus_code-0.4.1}/dashboard/package-lock.json +0 -0
  29. {argus_code-0.4.0 → argus_code-0.4.1}/dashboard/package.json +0 -0
  30. {argus_code-0.4.0 → argus_code-0.4.1}/dashboard/public/styles/global.css +0 -0
  31. {argus_code-0.4.0 → argus_code-0.4.1}/dashboard/src/layouts/Default.astro +0 -0
  32. {argus_code-0.4.0 → argus_code-0.4.1}/dashboard/src/pages/index.astro +0 -0
  33. {argus_code-0.4.0 → argus_code-0.4.1}/dashboard/src/pages/models.astro +0 -0
  34. {argus_code-0.4.0 → argus_code-0.4.1}/dashboard/src/pages/prompts.astro +0 -0
  35. {argus_code-0.4.0 → argus_code-0.4.1}/dashboard/src/pages/session.astro +0 -0
  36. {argus_code-0.4.0 → argus_code-0.4.1}/dashboard/src/pages/sessions/index.astro +0 -0
  37. {argus_code-0.4.0 → argus_code-0.4.1}/dashboard/src/pages/settings.astro +0 -0
  38. {argus_code-0.4.0 → argus_code-0.4.1}/dashboard/src/pages/tools.astro +0 -0
  39. {argus_code-0.4.0 → argus_code-0.4.1}/dashboard/src/pages/trends.astro +0 -0
  40. {argus_code-0.4.0 → argus_code-0.4.1}/dashboard/src/scripts/alerts.ts +0 -0
  41. {argus_code-0.4.0 → argus_code-0.4.1}/dashboard/src/scripts/api.ts +0 -0
  42. {argus_code-0.4.0 → argus_code-0.4.1}/dashboard/src/scripts/charts.ts +0 -0
  43. {argus_code-0.4.0 → argus_code-0.4.1}/dashboard/src/scripts/format.ts +0 -0
  44. {argus_code-0.4.0 → argus_code-0.4.1}/dashboard/src/styles/global.css +0 -0
  45. {argus_code-0.4.0 → argus_code-0.4.1}/dashboard/tsconfig.json +0 -0
  46. {argus_code-0.4.0 → argus_code-0.4.1}/dashboard-dist/_astro/charts.CQF9QNgP.js +0 -0
  47. {argus_code-0.4.0 → argus_code-0.4.1}/dashboard-dist/_astro/format.CgLh3UFa.js +0 -0
  48. {argus_code-0.4.0 → argus_code-0.4.1}/dashboard-dist/_astro/index.astro_astro_type_script_index_0_lang.UFVBMlig.js +0 -0
  49. {argus_code-0.4.0 → argus_code-0.4.1}/dashboard-dist/_astro/index.astro_astro_type_script_index_0_lang.cnW_pvh0.js +0 -0
  50. {argus_code-0.4.0 → argus_code-0.4.1}/dashboard-dist/_astro/installCanvasRenderer.XK5yP7fW.js +0 -0
  51. {argus_code-0.4.0 → argus_code-0.4.1}/dashboard-dist/_astro/models.astro_astro_type_script_index_0_lang.DsSR6D_a.js +0 -0
  52. {argus_code-0.4.0 → argus_code-0.4.1}/dashboard-dist/_astro/prompts.astro_astro_type_script_index_0_lang.C0Mv7sTw.js +0 -0
  53. {argus_code-0.4.0 → argus_code-0.4.1}/dashboard-dist/_astro/session.astro_astro_type_script_index_0_lang.IkwnKMSS.js +0 -0
  54. {argus_code-0.4.0 → argus_code-0.4.1}/dashboard-dist/_astro/settings.astro_astro_type_script_index_0_lang.Cs7bzN_0.js +0 -0
  55. {argus_code-0.4.0 → argus_code-0.4.1}/dashboard-dist/_astro/tools.astro_astro_type_script_index_0_lang.DTzvpY-G.js +0 -0
  56. {argus_code-0.4.0 → argus_code-0.4.1}/dashboard-dist/_astro/trends.astro_astro_type_script_index_0_lang.5olZlQfw.js +0 -0
  57. {argus_code-0.4.0 → argus_code-0.4.1}/dashboard-dist/index.html +0 -0
  58. {argus_code-0.4.0 → argus_code-0.4.1}/dashboard-dist/models/index.html +0 -0
  59. {argus_code-0.4.0 → argus_code-0.4.1}/dashboard-dist/prompts/index.html +0 -0
  60. {argus_code-0.4.0 → argus_code-0.4.1}/dashboard-dist/session/index.html +0 -0
  61. {argus_code-0.4.0 → argus_code-0.4.1}/dashboard-dist/sessions/index.html +0 -0
  62. {argus_code-0.4.0 → argus_code-0.4.1}/dashboard-dist/settings/index.html +0 -0
  63. {argus_code-0.4.0 → argus_code-0.4.1}/dashboard-dist/styles/global.css +0 -0
  64. {argus_code-0.4.0 → argus_code-0.4.1}/dashboard-dist/tools/index.html +0 -0
  65. {argus_code-0.4.0 → argus_code-0.4.1}/dashboard-dist/trends/index.html +0 -0
  66. {argus_code-0.4.0 → argus_code-0.4.1}/pricing/2026-05-02.json +0 -0
  67. {argus_code-0.4.0 → argus_code-0.4.1}/pricing/2026-06-12.json +0 -0
  68. {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/adapters/__init__.py +0 -0
  69. {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/adapters/base.py +0 -0
  70. {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/adapters/claude_code/__init__.py +0 -0
  71. {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/adapters/claude_code/adapter.py +0 -0
  72. {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/adapters/claude_code/discover.py +0 -0
  73. {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/adapters/claude_code/extract_tool_calls.py +0 -0
  74. {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/adapters/claude_code/extract_transcript.py +0 -0
  75. {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/adapters/claude_code/extract_turns.py +0 -0
  76. {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/adapters/claude_code/history_jsonl.py +0 -0
  77. {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/adapters/claude_code/ingest_file.py +0 -0
  78. {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/adapters/claude_code/model.py +0 -0
  79. {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/adapters/claude_code/schemas.py +0 -0
  80. {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/adapters/registry.py +0 -0
  81. {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/cli.py +0 -0
  82. {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/collector/__init__.py +0 -0
  83. {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/collector/aggregate.py +0 -0
  84. {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/collector/first_run.py +0 -0
  85. {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/collector/pipeline.py +0 -0
  86. {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/collector/rollup_subagents.py +0 -0
  87. {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/collector/scheduler.py +0 -0
  88. {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/collector/search_backfill.py +0 -0
  89. {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/collector/watcher.py +0 -0
  90. {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/core/__init__.py +0 -0
  91. {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/core/runtime.py +0 -0
  92. {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/daemon/__init__.py +0 -0
  93. {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/daemon/logging.py +0 -0
  94. {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/daemon/pidfile.py +0 -0
  95. {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/daemon/process.py +0 -0
  96. {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/daemon/service.py +0 -0
  97. {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/detectors/__init__.py +0 -0
  98. {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/detectors/base.py +0 -0
  99. {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/detectors/registry.py +0 -0
  100. {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/detectors/tool_error_rate_spike.py +0 -0
  101. {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/pricing/__init__.py +0 -0
  102. {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/pricing/compute.py +0 -0
  103. {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/pricing/load.py +0 -0
  104. {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/pricing/refresh.py +0 -0
  105. {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/pricing/types.py +0 -0
  106. {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/scaffold/__init__.py +0 -0
  107. {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/scaffold/scaffolder.py +0 -0
  108. {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/scaffold/snapshot.py +0 -0
  109. {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/scaffold/storage.py +0 -0
  110. {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/schema/__init__.py +0 -0
  111. {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/schema/types.py +0 -0
  112. {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/server/__init__.py +0 -0
  113. {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/server/api.py +0 -0
  114. {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/store/__init__.py +0 -0
  115. {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/store/migrations/__init__.py +0 -0
  116. {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/store/migrations/inline.py +0 -0
  117. {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/store/repository.py +0 -0
  118. {argus_code-0.4.0 → argus_code-0.4.1}/templates/default/.claude/agents/code-reviewer.md +0 -0
  119. {argus_code-0.4.0 → argus_code-0.4.1}/templates/default/.claude/agents/security-auditor.md +0 -0
  120. {argus_code-0.4.0 → argus_code-0.4.1}/templates/default/.claude/commands/commit.md +0 -0
  121. {argus_code-0.4.0 → argus_code-0.4.1}/templates/default/.claude/commands/deploy.md +0 -0
  122. {argus_code-0.4.0 → argus_code-0.4.1}/templates/default/.claude/commands/fix-issue.md +0 -0
  123. {argus_code-0.4.0 → argus_code-0.4.1}/templates/default/.claude/commands/pr.md +0 -0
  124. {argus_code-0.4.0 → argus_code-0.4.1}/templates/default/.claude/commands/review.md +0 -0
  125. {argus_code-0.4.0 → argus_code-0.4.1}/templates/default/.claude/rules/api-conventions.md +0 -0
  126. {argus_code-0.4.0 → argus_code-0.4.1}/templates/default/.claude/rules/code-style.md +0 -0
  127. {argus_code-0.4.0 → argus_code-0.4.1}/templates/default/.claude/rules/testing.md +0 -0
  128. {argus_code-0.4.0 → argus_code-0.4.1}/templates/default/.claude/settings.json +0 -0
  129. {argus_code-0.4.0 → argus_code-0.4.1}/templates/default/.claude/skills/example/SKILL.md +0 -0
  130. {argus_code-0.4.0 → argus_code-0.4.1}/templates/default/CLAUDE.md +0 -0
  131. {argus_code-0.4.0 → argus_code-0.4.1}/tests/__init__.py +0 -0
  132. {argus_code-0.4.0 → argus_code-0.4.1}/tests/adapters/__init__.py +0 -0
  133. {argus_code-0.4.0 → argus_code-0.4.1}/tests/adapters/claude_code/__init__.py +0 -0
  134. {argus_code-0.4.0 → argus_code-0.4.1}/tests/adapters/claude_code/test_adapter.py +0 -0
  135. {argus_code-0.4.0 → argus_code-0.4.1}/tests/adapters/claude_code/test_discover.py +0 -0
  136. {argus_code-0.4.0 → argus_code-0.4.1}/tests/adapters/claude_code/test_extract_tool_calls.py +0 -0
  137. {argus_code-0.4.0 → argus_code-0.4.1}/tests/adapters/claude_code/test_extract_transcript.py +0 -0
  138. {argus_code-0.4.0 → argus_code-0.4.1}/tests/adapters/claude_code/test_extract_turns.py +0 -0
  139. {argus_code-0.4.0 → argus_code-0.4.1}/tests/adapters/claude_code/test_history_jsonl.py +0 -0
  140. {argus_code-0.4.0 → argus_code-0.4.1}/tests/adapters/claude_code/test_ingest_file.py +0 -0
  141. {argus_code-0.4.0 → argus_code-0.4.1}/tests/adapters/claude_code/test_integration.py +0 -0
  142. {argus_code-0.4.0 → argus_code-0.4.1}/tests/adapters/claude_code/test_model.py +0 -0
  143. {argus_code-0.4.0 → argus_code-0.4.1}/tests/adapters/claude_code/test_schemas.py +0 -0
  144. {argus_code-0.4.0 → argus_code-0.4.1}/tests/adapters/test_registry.py +0 -0
  145. {argus_code-0.4.0 → argus_code-0.4.1}/tests/collector/__init__.py +0 -0
  146. {argus_code-0.4.0 → argus_code-0.4.1}/tests/collector/test_aggregate.py +0 -0
  147. {argus_code-0.4.0 → argus_code-0.4.1}/tests/collector/test_first_run.py +0 -0
  148. {argus_code-0.4.0 → argus_code-0.4.1}/tests/collector/test_pipeline.py +0 -0
  149. {argus_code-0.4.0 → argus_code-0.4.1}/tests/collector/test_rollup_subagents.py +0 -0
  150. {argus_code-0.4.0 → argus_code-0.4.1}/tests/collector/test_scheduler.py +0 -0
  151. {argus_code-0.4.0 → argus_code-0.4.1}/tests/collector/test_watcher.py +0 -0
  152. {argus_code-0.4.0 → argus_code-0.4.1}/tests/conftest.py +0 -0
  153. {argus_code-0.4.0 → argus_code-0.4.1}/tests/core/__init__.py +0 -0
  154. {argus_code-0.4.0 → argus_code-0.4.1}/tests/core/test_runtime.py +0 -0
  155. {argus_code-0.4.0 → argus_code-0.4.1}/tests/daemon/__init__.py +0 -0
  156. {argus_code-0.4.0 → argus_code-0.4.1}/tests/daemon/test_logs.py +0 -0
  157. {argus_code-0.4.0 → argus_code-0.4.1}/tests/daemon/test_pidfile.py +0 -0
  158. {argus_code-0.4.0 → argus_code-0.4.1}/tests/daemon/test_process.py +0 -0
  159. {argus_code-0.4.0 → argus_code-0.4.1}/tests/daemon/test_service.py +0 -0
  160. {argus_code-0.4.0 → argus_code-0.4.1}/tests/daemon/test_yield.py +0 -0
  161. {argus_code-0.4.0 → argus_code-0.4.1}/tests/detectors/__init__.py +0 -0
  162. {argus_code-0.4.0 → argus_code-0.4.1}/tests/detectors/test_registry.py +0 -0
  163. {argus_code-0.4.0 → argus_code-0.4.1}/tests/detectors/test_tool_error_rate_spike.py +0 -0
  164. {argus_code-0.4.0 → argus_code-0.4.1}/tests/pricing/__init__.py +0 -0
  165. {argus_code-0.4.0 → argus_code-0.4.1}/tests/pricing/test_compute.py +0 -0
  166. {argus_code-0.4.0 → argus_code-0.4.1}/tests/pricing/test_load.py +0 -0
  167. {argus_code-0.4.0 → argus_code-0.4.1}/tests/pricing/test_refresh.py +0 -0
  168. {argus_code-0.4.0 → argus_code-0.4.1}/tests/scaffold/__init__.py +0 -0
  169. {argus_code-0.4.0 → argus_code-0.4.1}/tests/scaffold/test_bundled_template.py +0 -0
  170. {argus_code-0.4.0 → argus_code-0.4.1}/tests/scaffold/test_cli_claude.py +0 -0
  171. {argus_code-0.4.0 → argus_code-0.4.1}/tests/scaffold/test_scaffolder.py +0 -0
  172. {argus_code-0.4.0 → argus_code-0.4.1}/tests/scaffold/test_snapshot.py +0 -0
  173. {argus_code-0.4.0 → argus_code-0.4.1}/tests/scaffold/test_storage.py +0 -0
  174. {argus_code-0.4.0 → argus_code-0.4.1}/tests/schema/__init__.py +0 -0
  175. {argus_code-0.4.0 → argus_code-0.4.1}/tests/schema/test_types.py +0 -0
  176. {argus_code-0.4.0 → argus_code-0.4.1}/tests/server/__init__.py +0 -0
  177. {argus_code-0.4.0 → argus_code-0.4.1}/tests/server/test_api.py +0 -0
  178. {argus_code-0.4.0 → argus_code-0.4.1}/tests/server/test_api_search.py +0 -0
  179. {argus_code-0.4.0 → argus_code-0.4.1}/tests/server/test_week_of.py +0 -0
  180. {argus_code-0.4.0 → argus_code-0.4.1}/tests/store/__init__.py +0 -0
  181. {argus_code-0.4.0 → argus_code-0.4.1}/tests/store/test_repository.py +0 -0
  182. {argus_code-0.4.0 → argus_code-0.4.1}/tests/test_e2e.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: argus-code
3
- Version: 0.4.0
3
+ Version: 0.4.1
4
4
  Summary: Local-first dashboard for Claude Code cost, token, tool-usage, and full-text search analytics
5
5
  Project-URL: Homepage, https://github.com/KrishBhimani/argus-code
6
6
  Project-URL: Repository, https://github.com/KrishBhimani/argus-code.git
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "argus-code"
3
- version = "0.4.0"
3
+ version = "0.4.1"
4
4
  description = "Local-first dashboard for Claude Code cost, token, tool-usage, and full-text search analytics"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -1,3 +1,3 @@
1
1
  """Argus — local-first analytics for Claude Code and other coding agents."""
2
2
 
3
- __version__ = "0.4.0"
3
+ __version__ = "0.4.1"
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
 
4
4
  import asyncio
5
5
  import logging
6
+ import signal
6
7
  from dataclasses import dataclass
7
8
  from pathlib import Path
8
9
  from typing import Any
@@ -84,7 +85,27 @@ def build_app(repo: Repository, opts: ServerOpts) -> FastAPI:
84
85
 
85
86
 
86
87
  def serve_blocking(app: FastAPI, *, host: str, port: int) -> None:
87
- """Run uvicorn in the current thread until SIGINT or .should_exit."""
88
+ """Run uvicorn in the current thread until SIGINT/SIGTERM or .should_exit.
89
+
90
+ We install our own signal handlers *before* calling ``server.run()`` so that
91
+ Ctrl+C produces a clean shutdown instead of the spurious
92
+ ``KeyboardInterrupt`` + ``CancelledError`` traceback uvicorn otherwise dumps.
93
+
94
+ Two layers conspire to print that traceback, and our handler defuses both:
95
+
96
+ 1. ``asyncio.run()`` installs its own SIGINT handler that cancels the main
97
+ task and raises ``KeyboardInterrupt`` mid-loop -- but only when the
98
+ current handler is ``signal.default_int_handler`` (see CPython
99
+ ``asyncio/runners.py``). Installing ours first means asyncio leaves
100
+ SIGINT alone.
101
+ 2. uvicorn's ``capture_signals()`` re-raises the captured signal after a
102
+ graceful shutdown. With ours as the restored handler, that re-raise
103
+ lands on a no-op instead of becoming a ``KeyboardInterrupt``.
104
+
105
+ During ``server.run()`` uvicorn's own ``handle_exit`` is the active handler
106
+ (it sets ``should_exit`` / ``force_exit`` on first/second signal); ours is
107
+ the benign backstop on either side of that window.
108
+ """
88
109
  config = uvicorn.Config(
89
110
  app,
90
111
  host=host,
@@ -93,7 +114,25 @@ def serve_blocking(app: FastAPI, *, host: str, port: int) -> None:
93
114
  access_log=False,
94
115
  )
95
116
  server = uvicorn.Server(config)
117
+
118
+ def _request_exit(_signum: int, _frame: Any) -> None:
119
+ if server.should_exit:
120
+ server.force_exit = True
121
+ server.should_exit = True
122
+
123
+ previous: dict[int, Any] = {}
124
+ for sig in (signal.SIGINT, signal.SIGTERM):
125
+ try:
126
+ previous[sig] = signal.signal(sig, _request_exit)
127
+ except (ValueError, OSError):
128
+ # Not the main thread, or signal unsupported on this platform.
129
+ pass
130
+
96
131
  try:
97
132
  server.run()
98
- except KeyboardInterrupt:
99
- pass
133
+ finally:
134
+ for sig, handler in previous.items():
135
+ try:
136
+ signal.signal(sig, handler)
137
+ except (ValueError, OSError):
138
+ pass
@@ -5,6 +5,7 @@ is better than a confusing OperationalError deep in searchPrompts later.
5
5
  """
6
6
  from __future__ import annotations
7
7
 
8
+ import re
8
9
  import sqlite3
9
10
  from pathlib import Path
10
11
 
@@ -40,6 +41,74 @@ def _assert_fts5(conn: sqlite3.Connection) -> None:
40
41
  )
41
42
 
42
43
 
44
+ _BLOCK_KW = re.compile(r"\b(BEGIN|CASE|END)\b", re.IGNORECASE)
45
+
46
+
47
+ def _split_statements(script: str) -> list[str]:
48
+ """Split a migration script into individual SQL statements.
49
+
50
+ SQLite separates statements with ';', but a ``CREATE TRIGGER`` body holds
51
+ its own ';'-terminated statements between ``BEGIN`` and ``END``. We track
52
+ nesting so a trigger is emitted as a single statement rather than being
53
+ chopped mid-body. ``CASE`` is counted as an opener too: it also closes with
54
+ ``END``, so counting it keeps ENDs balanced whether a ``CASE`` appears at
55
+ top level (MIGRATION_002's UPDATE) or inside a future trigger body.
56
+ """
57
+ statements: list[str] = []
58
+ buf: list[str] = []
59
+ depth = 0
60
+ for chunk in script.split(";"):
61
+ buf.append(chunk)
62
+ for kw in _BLOCK_KW.findall(chunk):
63
+ if kw.upper() in ("BEGIN", "CASE"):
64
+ depth += 1
65
+ elif depth > 0:
66
+ depth -= 1
67
+ if depth == 0:
68
+ stmt = ";".join(buf).strip()
69
+ buf = []
70
+ if stmt:
71
+ statements.append(stmt)
72
+ tail = ";".join(buf).strip()
73
+ if tail:
74
+ statements.append(tail)
75
+ return statements
76
+
77
+
78
+ def _run_migration(conn: sqlite3.Connection, version: int, sql: str) -> None:
79
+ """Apply one versioned migration and record schema_version atomically.
80
+
81
+ The migration's statements and the schema_version bump run inside a single
82
+ explicit transaction, so a crash mid-migration can never again leave the
83
+ DB with applied DDL but a stale recorded version (the bug that produced
84
+ ``duplicate column name`` on restart).
85
+
86
+ Recovery: SQLite has no ``ALTER TABLE ... ADD COLUMN IF NOT EXISTS``, so a
87
+ DB left half-migrated by the old non-atomic runner re-reports
88
+ ``duplicate column name``. Because the column is already present, we skip
89
+ just that statement and continue — every other migration statement is
90
+ ``IF NOT EXISTS`` / ``INSERT OR REPLACE`` / idempotent ``UPDATE``. This is
91
+ non-destructive: no row is dropped, deleted, or rewritten.
92
+ """
93
+ conn.execute("BEGIN")
94
+ try:
95
+ for stmt in _split_statements(sql):
96
+ try:
97
+ conn.execute(stmt)
98
+ except sqlite3.OperationalError as e:
99
+ if "duplicate column name" in str(e).lower():
100
+ continue # column already added by a prior partial run
101
+ raise
102
+ conn.execute(
103
+ "INSERT OR REPLACE INTO app_meta (key, value) VALUES ('schema_version', ?)",
104
+ (str(version),),
105
+ )
106
+ conn.execute("COMMIT")
107
+ except BaseException:
108
+ conn.execute("ROLLBACK")
109
+ raise
110
+
111
+
43
112
  def open_db(path: str | Path, *, read_only: bool = False) -> sqlite3.Connection:
44
113
  """Open the Argus SQLite DB at ``path``.
45
114
 
@@ -106,25 +175,18 @@ def open_db(path: str | Path, *, read_only: bool = False) -> sqlite3.Connection:
106
175
  ).fetchone()
107
176
  current = int(row["value"]) if row else 1
108
177
 
109
- if current < 2:
110
- conn.executescript(MIGRATION_002)
111
- current = 2
112
- if current < 3:
113
- conn.executescript(MIGRATION_003)
114
- current = 3
115
- if current < 4:
116
- conn.executescript(MIGRATION_004)
117
- current = 4
118
- if current < 5:
119
- conn.executescript(MIGRATION_005)
120
- current = 5
121
- if current < 6:
122
- conn.executescript(MIGRATION_006)
123
- current = 6
124
-
125
- conn.execute(
126
- "INSERT OR REPLACE INTO app_meta (key, value) VALUES ('schema_version', ?)",
127
- (str(SCHEMA_VERSION),),
178
+ # Each migration commits its DDL and its schema_version bump together, so
179
+ # the recorded version always matches the applied schema. See _run_migration.
180
+ versioned = (
181
+ (2, MIGRATION_002),
182
+ (3, MIGRATION_003),
183
+ (4, MIGRATION_004),
184
+ (5, MIGRATION_005),
185
+ (6, MIGRATION_006),
128
186
  )
187
+ for version, sql in versioned:
188
+ if current < version:
189
+ _run_migration(conn, version, sql)
190
+ current = version
129
191
 
130
192
  return conn
@@ -1,13 +1,16 @@
1
1
  """Server bootstrap — API + static dashboard served from the same app."""
2
2
  from __future__ import annotations
3
3
 
4
+ import signal
4
5
  from pathlib import Path
5
6
 
7
+ from fastapi import FastAPI
6
8
  from fastapi.testclient import TestClient
7
9
 
8
10
  from argus.collector.first_run import IngestStatus
9
11
  from argus.pricing.types import PricingTable
10
- from argus.server.app import ServerOpts, build_app
12
+ from argus.server import app as appmod
13
+ from argus.server.app import ServerOpts, build_app, serve_blocking
11
14
 
12
15
 
13
16
  def test_app_serves_api_and_static_from_same_root(repo, tmp_path: Path):
@@ -123,3 +126,47 @@ def test_cross_origin_post_to_alerts_seen_is_rejected(repo, tmp_path: Path):
123
126
  headers={"origin": "http://evil.example.com"},
124
127
  )
125
128
  assert r.status_code == 403
129
+
130
+
131
+ def test_serve_blocking_ctrl_c_shuts_down_without_traceback(monkeypatch):
132
+ """Ctrl+C must shut the server down cleanly, not dump the uvicorn
133
+ KeyboardInterrupt/CancelledError traceback.
134
+
135
+ Two facts make that happen, both asserted here against a fake ``run()`` that
136
+ stands in for the real serve loop:
137
+
138
+ 1. By the time uvicorn's loop is running, the SIGINT handler is *ours*,
139
+ not ``signal.default_int_handler`` -- so ``asyncio.run()``'s Runner
140
+ declines to install the handler that raises ``KeyboardInterrupt``.
141
+ 2. A SIGINT delivered to our handler (including the re-raise uvicorn's
142
+ ``capture_signals`` performs on exit) sets ``should_exit`` /
143
+ ``force_exit`` instead of raising.
144
+
145
+ It also pins the cleanup contract: prior signal handlers are restored.
146
+ """
147
+ seen: dict[str, object] = {}
148
+
149
+ def fake_run(self) -> None:
150
+ active = signal.getsignal(signal.SIGINT)
151
+ seen["sigint_is_ours"] = active is not signal.default_int_handler
152
+ # First Ctrl+C -> graceful shutdown requested.
153
+ active(signal.SIGINT, None)
154
+ seen["should_exit"] = self.should_exit
155
+ # Second Ctrl+C -> force exit (the path that produced the original
156
+ # traceback at runners.py:157 `raise KeyboardInterrupt()`).
157
+ active(signal.SIGINT, None)
158
+ seen["force_exit"] = self.force_exit
159
+ # uvicorn's capture_signals re-raises the captured signal on exit; with
160
+ # our handler installed this must be a no-op, not a KeyboardInterrupt.
161
+ signal.raise_signal(signal.SIGINT)
162
+
163
+ monkeypatch.setattr(appmod.uvicorn.Server, "run", fake_run)
164
+
165
+ before = signal.getsignal(signal.SIGINT)
166
+ serve_blocking(FastAPI(), host="127.0.0.1", port=0) # must not raise
167
+ after = signal.getsignal(signal.SIGINT)
168
+
169
+ assert seen["sigint_is_ours"] is True
170
+ assert seen["should_exit"] is True
171
+ assert seen["force_exit"] is True
172
+ assert after is before # handlers restored on exit
@@ -117,6 +117,42 @@ def test_migration_006_index_exists(db):
117
117
  assert "idx_segments_tool_use" in names
118
118
 
119
119
 
120
+ def test_open_db_recovers_from_half_applied_migration(db_path):
121
+ """A DB whose column was added but whose schema_version never advanced
122
+ must re-open cleanly, not crash with 'duplicate column name'.
123
+
124
+ Reproduces the field failure: MIGRATION_006 added transcript_segments.
125
+ tool_use_id but schema_version stayed < 6 (migrations were committed
126
+ per-statement while the version was written only at the very end). The
127
+ re-run then hit a non-idempotent ALTER TABLE ADD COLUMN.
128
+ """
129
+ # Build a faithfully half-migrated DB: column present, index missing,
130
+ # schema_version pinned back to 5.
131
+ conn = open_db(db_path)
132
+ conn.execute("DROP INDEX IF EXISTS idx_segments_tool_use")
133
+ conn.execute(
134
+ "INSERT OR REPLACE INTO app_meta (key, value) VALUES ('schema_version', '5')"
135
+ )
136
+ conn.close()
137
+
138
+ # Re-open must not raise, must finish at v6, and must heal the missing index.
139
+ conn = open_db(db_path)
140
+ try:
141
+ ver = conn.execute(
142
+ "SELECT value FROM app_meta WHERE key = 'schema_version'"
143
+ ).fetchone()
144
+ assert int(ver["value"]) == 6
145
+ cols = {r["name"] for r in conn.execute("PRAGMA table_info(transcript_segments)")}
146
+ assert "tool_use_id" in cols
147
+ idx = {
148
+ r["name"]
149
+ for r in conn.execute("SELECT name FROM sqlite_master WHERE type = 'index'")
150
+ }
151
+ assert "idx_segments_tool_use" in idx
152
+ finally:
153
+ conn.close()
154
+
155
+
120
156
  def test_alerts_has_resolved_at_column(db_path):
121
157
  conn = open_db(db_path)
122
158
  try: