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.
- {argus_code-0.4.0 → argus_code-0.4.1}/PKG-INFO +1 -1
- {argus_code-0.4.0 → argus_code-0.4.1}/pyproject.toml +1 -1
- {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/__init__.py +1 -1
- {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/server/app.py +42 -3
- {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/store/db.py +81 -19
- {argus_code-0.4.0 → argus_code-0.4.1}/tests/server/test_server.py +48 -1
- {argus_code-0.4.0 → argus_code-0.4.1}/tests/store/test_db.py +36 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/uv.lock +983 -983
- {argus_code-0.4.0 → argus_code-0.4.1}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/.github/pull_request_template.md +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/.gitignore +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/.npmrc +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/ARCHITECTURE.md +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/CONTRIBUTING.md +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/LICENSE +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/NOTICE +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/PRD.md +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/README.md +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/SECURITY.md +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/TESTING.md +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/dashboard/.astro/content-assets.mjs +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/dashboard/.astro/content-modules.mjs +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/dashboard/.astro/content.d.ts +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/dashboard/.astro/types.d.ts +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/dashboard/astro.config.mjs +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/dashboard/package-lock.json +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/dashboard/package.json +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/dashboard/public/styles/global.css +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/dashboard/src/layouts/Default.astro +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/dashboard/src/pages/index.astro +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/dashboard/src/pages/models.astro +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/dashboard/src/pages/prompts.astro +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/dashboard/src/pages/session.astro +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/dashboard/src/pages/sessions/index.astro +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/dashboard/src/pages/settings.astro +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/dashboard/src/pages/tools.astro +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/dashboard/src/pages/trends.astro +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/dashboard/src/scripts/alerts.ts +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/dashboard/src/scripts/api.ts +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/dashboard/src/scripts/charts.ts +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/dashboard/src/scripts/format.ts +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/dashboard/src/styles/global.css +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/dashboard/tsconfig.json +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/dashboard-dist/_astro/charts.CQF9QNgP.js +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/dashboard-dist/_astro/format.CgLh3UFa.js +0 -0
- {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
- {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
- {argus_code-0.4.0 → argus_code-0.4.1}/dashboard-dist/_astro/installCanvasRenderer.XK5yP7fW.js +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {argus_code-0.4.0 → argus_code-0.4.1}/dashboard-dist/index.html +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/dashboard-dist/models/index.html +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/dashboard-dist/prompts/index.html +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/dashboard-dist/session/index.html +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/dashboard-dist/sessions/index.html +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/dashboard-dist/settings/index.html +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/dashboard-dist/styles/global.css +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/dashboard-dist/tools/index.html +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/dashboard-dist/trends/index.html +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/pricing/2026-05-02.json +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/pricing/2026-06-12.json +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/adapters/__init__.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/adapters/base.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/adapters/claude_code/__init__.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/adapters/claude_code/adapter.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/adapters/claude_code/discover.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/adapters/claude_code/extract_tool_calls.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/adapters/claude_code/extract_transcript.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/adapters/claude_code/extract_turns.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/adapters/claude_code/history_jsonl.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/adapters/claude_code/ingest_file.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/adapters/claude_code/model.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/adapters/claude_code/schemas.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/adapters/registry.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/cli.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/collector/__init__.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/collector/aggregate.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/collector/first_run.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/collector/pipeline.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/collector/rollup_subagents.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/collector/scheduler.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/collector/search_backfill.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/collector/watcher.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/core/__init__.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/core/runtime.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/daemon/__init__.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/daemon/logging.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/daemon/pidfile.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/daemon/process.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/daemon/service.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/detectors/__init__.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/detectors/base.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/detectors/registry.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/detectors/tool_error_rate_spike.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/pricing/__init__.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/pricing/compute.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/pricing/load.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/pricing/refresh.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/pricing/types.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/scaffold/__init__.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/scaffold/scaffolder.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/scaffold/snapshot.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/scaffold/storage.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/schema/__init__.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/schema/types.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/server/__init__.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/server/api.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/store/__init__.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/store/migrations/__init__.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/store/migrations/inline.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/python/argus/store/repository.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/templates/default/.claude/agents/code-reviewer.md +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/templates/default/.claude/agents/security-auditor.md +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/templates/default/.claude/commands/commit.md +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/templates/default/.claude/commands/deploy.md +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/templates/default/.claude/commands/fix-issue.md +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/templates/default/.claude/commands/pr.md +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/templates/default/.claude/commands/review.md +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/templates/default/.claude/rules/api-conventions.md +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/templates/default/.claude/rules/code-style.md +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/templates/default/.claude/rules/testing.md +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/templates/default/.claude/settings.json +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/templates/default/.claude/skills/example/SKILL.md +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/templates/default/CLAUDE.md +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/tests/__init__.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/tests/adapters/__init__.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/tests/adapters/claude_code/__init__.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/tests/adapters/claude_code/test_adapter.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/tests/adapters/claude_code/test_discover.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/tests/adapters/claude_code/test_extract_tool_calls.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/tests/adapters/claude_code/test_extract_transcript.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/tests/adapters/claude_code/test_extract_turns.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/tests/adapters/claude_code/test_history_jsonl.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/tests/adapters/claude_code/test_ingest_file.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/tests/adapters/claude_code/test_integration.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/tests/adapters/claude_code/test_model.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/tests/adapters/claude_code/test_schemas.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/tests/adapters/test_registry.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/tests/collector/__init__.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/tests/collector/test_aggregate.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/tests/collector/test_first_run.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/tests/collector/test_pipeline.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/tests/collector/test_rollup_subagents.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/tests/collector/test_scheduler.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/tests/collector/test_watcher.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/tests/conftest.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/tests/core/__init__.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/tests/core/test_runtime.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/tests/daemon/__init__.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/tests/daemon/test_logs.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/tests/daemon/test_pidfile.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/tests/daemon/test_process.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/tests/daemon/test_service.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/tests/daemon/test_yield.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/tests/detectors/__init__.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/tests/detectors/test_registry.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/tests/detectors/test_tool_error_rate_spike.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/tests/pricing/__init__.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/tests/pricing/test_compute.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/tests/pricing/test_load.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/tests/pricing/test_refresh.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/tests/scaffold/__init__.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/tests/scaffold/test_bundled_template.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/tests/scaffold/test_cli_claude.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/tests/scaffold/test_scaffolder.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/tests/scaffold/test_snapshot.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/tests/scaffold/test_storage.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/tests/schema/__init__.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/tests/schema/test_types.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/tests/server/__init__.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/tests/server/test_api.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/tests/server/test_api_search.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/tests/server/test_week_of.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/tests/store/__init__.py +0 -0
- {argus_code-0.4.0 → argus_code-0.4.1}/tests/store/test_repository.py +0 -0
- {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.
|
|
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
|
|
@@ -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
|
-
|
|
99
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
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:
|