code-coordinator 0.5.46__py3-none-any.whl
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.
- code_coordinator-0.5.46.dist-info/METADATA +625 -0
- code_coordinator-0.5.46.dist-info/RECORD +295 -0
- code_coordinator-0.5.46.dist-info/WHEEL +5 -0
- code_coordinator-0.5.46.dist-info/entry_points.txt +2 -0
- code_coordinator-0.5.46.dist-info/licenses/LICENSE +110 -0
- code_coordinator-0.5.46.dist-info/top_level.txt +1 -0
- coord/__init__.py +176 -0
- coord/_board_mapping.py +229 -0
- coord/acceptance.py +468 -0
- coord/acceptance_drivers.py +632 -0
- coord/agent.py +7517 -0
- coord/agent_app.py +1555 -0
- coord/agent_update.py +417 -0
- coord/agents/opencode/.gitignore +13 -0
- coord/agents/opencode/agents/work.md +129 -0
- coord/agents/opencode/routing.jsonc +49 -0
- coord/audit.py +301 -0
- coord/auto_loop.py +1440 -0
- coord/board_bool_guard.py +72 -0
- coord/board_service.py +141 -0
- coord/board_wire.py +309 -0
- coord/brain.py +581 -0
- coord/branch_model.py +214 -0
- coord/cargo_cache.py +258 -0
- coord/ci_github.py +386 -0
- coord/ci_store.py +560 -0
- coord/claim.py +353 -0
- coord/cli.py +454 -0
- coord/client.py +610 -0
- coord/commands/__init__.py +1 -0
- coord/commands/_common.py +329 -0
- coord/commands/acceptance.py +916 -0
- coord/commands/agent_ops.py +1339 -0
- coord/commands/audit.py +131 -0
- coord/commands/chat.py +320 -0
- coord/commands/dispatch.py +1780 -0
- coord/commands/dispatch_workers.py +4894 -0
- coord/commands/drive.py +616 -0
- coord/commands/drive_queue.py +1203 -0
- coord/commands/gate_a.py +217 -0
- coord/commands/gates.py +89 -0
- coord/commands/issues.py +681 -0
- coord/commands/lifecycle.py +513 -0
- coord/commands/merge.py +1900 -0
- coord/commands/milestone.py +2081 -0
- coord/commands/plan_followup.py +1243 -0
- coord/commands/plans.py +156 -0
- coord/commands/release.py +2232 -0
- coord/commands/report.py +341 -0
- coord/commands/review.py +1523 -0
- coord/commands/scorecard.py +252 -0
- coord/commands/sessions.py +1930 -0
- coord/commands/setup.py +576 -0
- coord/commands/status.py +2089 -0
- coord/commands/terminal.py +385 -0
- coord/commands/test_gate.py +775 -0
- coord/commands/tui.py +288 -0
- coord/comments.py +718 -0
- coord/config.py +3032 -0
- coord/conflict_fix.py +633 -0
- coord/dao.py +483 -0
- coord/dashboard/__init__.py +0 -0
- coord/dashboard/fixture.py +376 -0
- coord/dashboard/index.html +658 -0
- coord/dashboard/server.py +1894 -0
- coord/dashboard/terminal.py +382 -0
- coord/dashboard/webapp/.gitignore +9 -0
- coord/dashboard/webapp/components.json +17 -0
- coord/dashboard/webapp/dist/assets/Gallery-da3qNiIw.js +71 -0
- coord/dashboard/webapp/dist/assets/Terminal-9CEnUXvW.css +32 -0
- coord/dashboard/webapp/dist/assets/Terminal-skVFCxPU.js +63 -0
- coord/dashboard/webapp/dist/assets/index-DltfZR5f.js +184 -0
- coord/dashboard/webapp/dist/assets/index-Dq4kwTdw.css +1 -0
- coord/dashboard/webapp/dist/assets/workbox-window.prod.es5-BqEJf4Xk.js +2 -0
- coord/dashboard/webapp/dist/icons/icon-192.png +0 -0
- coord/dashboard/webapp/dist/icons/icon-512.png +0 -0
- coord/dashboard/webapp/dist/icons/icon.svg +5 -0
- coord/dashboard/webapp/dist/index.html +38 -0
- coord/dashboard/webapp/dist/manifest.webmanifest +1 -0
- coord/dashboard/webapp/dist/sw.js +1 -0
- coord/dashboard/webapp/dist/workbox-e4022e15.js +1 -0
- coord/dashboard/webapp/e2e/available-gates-terminal.spec.ts +75 -0
- coord/dashboard/webapp/e2e/deep-link.spec.ts +172 -0
- coord/dashboard/webapp/e2e/fixtureServer.ts +155 -0
- coord/dashboard/webapp/e2e/live-update-fixture.spec.ts +113 -0
- coord/dashboard/webapp/e2e/realtime.spec.ts +238 -0
- coord/dashboard/webapp/e2e/shell.spec.ts +309 -0
- coord/dashboard/webapp/e2e/smoke.spec.ts +191 -0
- coord/dashboard/webapp/e2e/terminal.spec.ts +420 -0
- coord/dashboard/webapp/e2e/theme.spec.ts +138 -0
- coord/dashboard/webapp/eslint.config.js +20 -0
- coord/dashboard/webapp/index.html +37 -0
- coord/dashboard/webapp/node_modules/flatted/python/flatted.py +144 -0
- coord/dashboard/webapp/package-lock.json +10584 -0
- coord/dashboard/webapp/package.json +63 -0
- coord/dashboard/webapp/playwright.acceptance.config.ts +166 -0
- coord/dashboard/webapp/playwright.config.ts +93 -0
- coord/dashboard/webapp/postcss.config.js +6 -0
- coord/dashboard/webapp/public/icons/icon-192.png +0 -0
- coord/dashboard/webapp/public/icons/icon-512.png +0 -0
- coord/dashboard/webapp/public/icons/icon.svg +5 -0
- coord/dashboard/webapp/src/App.tsx +140 -0
- coord/dashboard/webapp/src/api/client.ts +199 -0
- coord/dashboard/webapp/src/api/generated.ts +176 -0
- coord/dashboard/webapp/src/components/ConnectionBadge.tsx +52 -0
- coord/dashboard/webapp/src/components/Detail.tsx +800 -0
- coord/dashboard/webapp/src/components/Gallery.tsx +341 -0
- coord/dashboard/webapp/src/components/Home.tsx +435 -0
- coord/dashboard/webapp/src/components/MobileKeyBar.tsx +280 -0
- coord/dashboard/webapp/src/components/PanelHeader.tsx +59 -0
- coord/dashboard/webapp/src/components/PipelineCard.tsx +168 -0
- coord/dashboard/webapp/src/components/SessionCard.tsx +99 -0
- coord/dashboard/webapp/src/components/SessionDetail.tsx +140 -0
- coord/dashboard/webapp/src/components/SessionsList.tsx +81 -0
- coord/dashboard/webapp/src/components/Terminal.tsx +376 -0
- coord/dashboard/webapp/src/components/__tests__/ConnectionBadge.test.tsx +81 -0
- coord/dashboard/webapp/src/components/__tests__/Detail.test.tsx +680 -0
- coord/dashboard/webapp/src/components/__tests__/Gallery.test.tsx +83 -0
- coord/dashboard/webapp/src/components/__tests__/Home.test.tsx +271 -0
- coord/dashboard/webapp/src/components/__tests__/MobileKeyBar.test.tsx +197 -0
- coord/dashboard/webapp/src/components/__tests__/PipelineCard.test.tsx +143 -0
- coord/dashboard/webapp/src/components/__tests__/SessionCard.test.tsx +106 -0
- coord/dashboard/webapp/src/components/__tests__/Terminal.test.tsx +504 -0
- coord/dashboard/webapp/src/components/ui/badge.tsx +41 -0
- coord/dashboard/webapp/src/components/ui/button.tsx +54 -0
- coord/dashboard/webapp/src/components/ui/card.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/dialog.tsx +99 -0
- coord/dashboard/webapp/src/components/ui/dropdown-menu.tsx +189 -0
- coord/dashboard/webapp/src/components/ui/empty-state.tsx +35 -0
- coord/dashboard/webapp/src/components/ui/sheet.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/skeleton.tsx +9 -0
- coord/dashboard/webapp/src/components/ui/tabs.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/theme-provider.tsx +78 -0
- coord/dashboard/webapp/src/components/ui/theme-toggle.tsx +20 -0
- coord/dashboard/webapp/src/components/ui/toast.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/toaster.tsx +30 -0
- coord/dashboard/webapp/src/components/ui/tooltip.tsx +26 -0
- coord/dashboard/webapp/src/components/ui/use-toast.ts +134 -0
- coord/dashboard/webapp/src/index.css +210 -0
- coord/dashboard/webapp/src/lib/pipeline.ts +29 -0
- coord/dashboard/webapp/src/lib/utils.ts +6 -0
- coord/dashboard/webapp/src/main.tsx +46 -0
- coord/dashboard/webapp/src/realtime/RealtimeProvider.tsx +112 -0
- coord/dashboard/webapp/src/realtime/__tests__/RealtimeProvider.test.tsx +189 -0
- coord/dashboard/webapp/src/realtime/__tests__/connection.test.ts +255 -0
- coord/dashboard/webapp/src/realtime/connection.ts +227 -0
- coord/dashboard/webapp/src/realtime/events.ts +100 -0
- coord/dashboard/webapp/src/routes/__tests__/paths.test.ts +92 -0
- coord/dashboard/webapp/src/routes/paths.ts +92 -0
- coord/dashboard/webapp/src/shell/ActivityRail.tsx +335 -0
- coord/dashboard/webapp/src/shell/AppShell.tsx +276 -0
- coord/dashboard/webapp/src/shell/ComingSoon.tsx +33 -0
- coord/dashboard/webapp/src/shell/EmptyDetail.tsx +26 -0
- coord/dashboard/webapp/src/shell/RouteNotFound.tsx +33 -0
- coord/dashboard/webapp/src/shell/ShellLayout.tsx +147 -0
- coord/dashboard/webapp/src/shell/StatusBar.tsx +46 -0
- coord/dashboard/webapp/src/shell/__tests__/ShellLayout.test.tsx +520 -0
- coord/dashboard/webapp/src/shell/__tests__/shellState.test.ts +95 -0
- coord/dashboard/webapp/src/shell/__tests__/stubViewport.ts +40 -0
- coord/dashboard/webapp/src/shell/breakpoints.ts +87 -0
- coord/dashboard/webapp/src/shell/railItems.ts +105 -0
- coord/dashboard/webapp/src/shell/shellState.ts +174 -0
- coord/dashboard/webapp/src/shell/useRegionFocus.ts +95 -0
- coord/dashboard/webapp/src/test-setup.ts +41 -0
- coord/dashboard/webapp/src/vite-env.d.ts +2 -0
- coord/dashboard/webapp/tailwind.config.js +140 -0
- coord/dashboard/webapp/tsconfig.json +25 -0
- coord/dashboard/webapp/tsconfig.node.json +11 -0
- coord/dashboard/webapp/vite.config.ts +71 -0
- coord/db.py +1076 -0
- coord/dead_end.py +332 -0
- coord/deploy/README.md +33 -0
- coord/deploy/coord-agent.service +89 -0
- coord/deploy/coord-db-backup.service +60 -0
- coord/deploy/coord-db-backup.sh +74 -0
- coord/deploy/coord-db-backup.timer +18 -0
- coord/deploy/coord-drive-queue.service +117 -0
- coord/deploy/coord-drive-queue.timer +39 -0
- coord/deploy/coord-notify.service +48 -0
- coord/deploy/coord-notify.timer +24 -0
- coord/deploy/coord-release-propagate.service +83 -0
- coord/deploy/coord-release-propagate.timer +38 -0
- coord/deploy/coord-release-window.service +119 -0
- coord/deploy/coord-release-window.timer +36 -0
- coord/deploy/coord-serve.service +82 -0
- coord/deploy/coord-web-dist-build.service +43 -0
- coord/deploy/coord-web-dist-build.timer +36 -0
- coord/deploy/coord-web.service +125 -0
- coord/deploy_manifest.py +80 -0
- coord/deploy_units.py +384 -0
- coord/deps.py +115 -0
- coord/diagnose.py +1623 -0
- coord/dispatch.py +1009 -0
- coord/dist_name.py +123 -0
- coord/drive.py +3101 -0
- coord/drive_queue.py +2298 -0
- coord/drive_state.py +870 -0
- coord/events.py +381 -0
- coord/failure_class.py +914 -0
- coord/filelock.py +168 -0
- coord/fleet_config_health.py +300 -0
- coord/freshness.py +206 -0
- coord/gate_a.py +469 -0
- coord/gate_b.py +411 -0
- coord/gate_snapshot.py +385 -0
- coord/gates.py +582 -0
- coord/github_ops.py +1954 -0
- coord/goal.py +125 -0
- coord/graph_health.py +348 -0
- coord/health/__init__.py +69 -0
- coord/health/aggregate.py +129 -0
- coord/health/checks/__init__.py +13 -0
- coord/health/checks/agent_install.py +280 -0
- coord/health/checks/cargo_targets.py +171 -0
- coord/health/checks/claude_binary.py +65 -0
- coord/health/checks/deploy_lane_facts.py +458 -0
- coord/health/checks/disk.py +99 -0
- coord/health/checks/fleet_board.py +89 -0
- coord/health/checks/fleet_deploy_lanes.py +469 -0
- coord/health/checks/fleet_phantom.py +69 -0
- coord/health/checks/fleet_unit_drift.py +151 -0
- coord/health/checks/graph.py +192 -0
- coord/health/checks/plan_usage.py +88 -0
- coord/health/checks/repo_state.py +161 -0
- coord/health/checks/spawned_coord.py +465 -0
- coord/health/checks/timer_active.py +254 -0
- coord/health/checks/toolchain.py +547 -0
- coord/health/checks/unit_drift.py +648 -0
- coord/health/checks/unit_enablement.py +171 -0
- coord/health/checks/worktrees.py +96 -0
- coord/health/cli.py +121 -0
- coord/health/context.py +106 -0
- coord/health/fleet_snapshot.py +477 -0
- coord/health/models.py +250 -0
- coord/health/pypi.py +231 -0
- coord/health/registry.py +240 -0
- coord/health/render.py +82 -0
- coord/health/units.py +60 -0
- coord/hooks.py +106 -0
- coord/housekeeping.py +204 -0
- coord/interactive.py +4286 -0
- coord/issue_store.py +1496 -0
- coord/liveness_auditor.py +293 -0
- coord/machine_pause.py +755 -0
- coord/merge_queue.py +4681 -0
- coord/milestone_chat.py +600 -0
- coord/milestone_dispatch.py +943 -0
- coord/milestone_gate.py +709 -0
- coord/milestone_order.py +840 -0
- coord/mock_author.py +334 -0
- coord/models.py +891 -0
- coord/network.py +269 -0
- coord/new_issue_chat.py +229 -0
- coord/notify.py +3226 -0
- coord/openapi.py +404 -0
- coord/overlap_fence.py +133 -0
- coord/parentage.py +200 -0
- coord/parentage_github.py +58 -0
- coord/pipeline.py +481 -0
- coord/plan_parser.py +266 -0
- coord/plans.py +543 -0
- coord/platform_paths.py +43 -0
- coord/pr_body_lint.py +67 -0
- coord/prereqs.py +533 -0
- coord/progress.py +425 -0
- coord/providers/__init__.py +683 -0
- coord/providers/base.py +218 -0
- coord/providers/claude.py +284 -0
- coord/providers/claude_pty.py +610 -0
- coord/providers/opencode.py +896 -0
- coord/reconcile.py +2233 -0
- coord/refine_chat.py +485 -0
- coord/release_cordon.py +525 -0
- coord/release_propagate.py +1176 -0
- coord/release_verify.py +777 -0
- coord/release_window.py +322 -0
- coord/reports.py +1643 -0
- coord/revalidate.py +1101 -0
- coord/review.py +3317 -0
- coord/scorecard.py +484 -0
- coord/serve_app.py +7192 -0
- coord/skills/update-issue/SKILL.md +93 -0
- coord/smoke.py +1030 -0
- coord/split_work.py +210 -0
- coord/stage_projection.py +650 -0
- coord/state.py +5720 -0
- coord/test_author.py +1064 -0
- coord/test_chat.py +352 -0
- coord/test_orchestrator.py +494 -0
- coord/test_report.py +178 -0
- coord/tui_release.py +271 -0
- coord/usage.py +753 -0
- coord/usage_limits.py +358 -0
- coord/usage_rollup.py +709 -0
- coord/worker_events.py +954 -0
coord/audit.py
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""Durable, append-only audit trail (#1036 — Audit Trail epic, issue A).
|
|
2
|
+
|
|
3
|
+
``record_audit()`` is called from the ``state._*_local`` / ``issue_store``
|
|
4
|
+
write choke points — the handful of functions every board mutation, whether
|
|
5
|
+
it arrives from a thin client or lands directly on the daemon, funnels
|
|
6
|
+
through (canonical example: ``state._record_test_verdict_local``). Hooking
|
|
7
|
+
there guarantees one row per real transition regardless of topology, without
|
|
8
|
+
touching the ~30 CLI call sites that ultimately reach those writers.
|
|
9
|
+
|
|
10
|
+
This module is deliberately dumb: one table, one INSERT, best-effort. It
|
|
11
|
+
does not define an event taxonomy beyond the ``tier``/``category`` columns
|
|
12
|
+
described in the issue — callers pick their own ``event_type`` strings,
|
|
13
|
+
reusing the ``coord:event=`` names from :mod:`coord.comments` where they
|
|
14
|
+
already exist (``EVENT_COMPLETION`` etc.) so the audit log and the GitHub
|
|
15
|
+
message bus agree on vocabulary.
|
|
16
|
+
|
|
17
|
+
**Never raises into the caller.** A board mutation must always succeed even
|
|
18
|
+
if the audit write fails (disk full, locked DB, schema drift on an old
|
|
19
|
+
checkout) — the write that rode in on is the one that matters; the audit
|
|
20
|
+
row is best-effort observability on top of it.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import logging
|
|
27
|
+
import time
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from coord.db import get_connection
|
|
31
|
+
|
|
32
|
+
_log = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
__all__ = ["record_audit", "query_audit_log"]
|
|
35
|
+
|
|
36
|
+
# Valid values are documented in the issue but not enforced here — callers
|
|
37
|
+
# are all internal (coord.state / coord.issue_store), and rejecting an
|
|
38
|
+
# unrecognized value would defeat "never raises into the caller".
|
|
39
|
+
_VALID_TIERS = ("business", "operational")
|
|
40
|
+
|
|
41
|
+
# #1037: read-side defaults for the paginated `/audit` endpoint / `coord
|
|
42
|
+
# audit` CLI. Hard-capped so a client can't request the whole table in one
|
|
43
|
+
# shot (the endpoint is explicitly NOT the /board "everything" snapshot).
|
|
44
|
+
DEFAULT_LIMIT = 200
|
|
45
|
+
MAX_LIMIT = 500
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def record_audit(
|
|
49
|
+
*,
|
|
50
|
+
tier: str,
|
|
51
|
+
category: str,
|
|
52
|
+
event_type: str,
|
|
53
|
+
actor: str,
|
|
54
|
+
summary: str,
|
|
55
|
+
ts: float | None = None,
|
|
56
|
+
repo: str | None = None,
|
|
57
|
+
issue: int | None = None,
|
|
58
|
+
assignment_id: str | None = None,
|
|
59
|
+
machine: str | None = None,
|
|
60
|
+
details: dict[str, Any] | None = None,
|
|
61
|
+
) -> None:
|
|
62
|
+
"""Append one row to ``audit_log``. Best-effort — swallows all failures.
|
|
63
|
+
|
|
64
|
+
``tier`` is ``"business"`` (a real board transition — dispatch, test
|
|
65
|
+
verdict, review verdict, merge, ...) or ``"operational"`` (daemon-tick
|
|
66
|
+
housekeeping; out of scope for this issue, reserved for a later one).
|
|
67
|
+
``details`` is JSON-serialized into ``details_json``; pass only
|
|
68
|
+
JSON-safe values (str/int/float/bool/None/dict/list).
|
|
69
|
+
|
|
70
|
+
Also performs the opportunistic ``audit.max_rows`` trim (#1036
|
|
71
|
+
deliverable 4) after a successful insert, when the config knob is set
|
|
72
|
+
above its default of ``0`` (unlimited).
|
|
73
|
+
|
|
74
|
+
#1038: when ``tier="operational"`` and ``audit.level`` is set to
|
|
75
|
+
``"business"`` (default ``"operational"``), the row is dropped here —
|
|
76
|
+
the single choke point every operational-tier caller funnels through —
|
|
77
|
+
so callers (the daemon-tick hooks) stay unconditional. Business-tier
|
|
78
|
+
rows are never gated by this check.
|
|
79
|
+
"""
|
|
80
|
+
try:
|
|
81
|
+
if tier == "operational" and _resolve_level() == "business":
|
|
82
|
+
return
|
|
83
|
+
_record_audit_unsafe(
|
|
84
|
+
tier=tier,
|
|
85
|
+
category=category,
|
|
86
|
+
event_type=event_type,
|
|
87
|
+
actor=actor,
|
|
88
|
+
summary=summary,
|
|
89
|
+
ts=ts,
|
|
90
|
+
repo=repo,
|
|
91
|
+
issue=issue,
|
|
92
|
+
assignment_id=assignment_id,
|
|
93
|
+
machine=machine,
|
|
94
|
+
details=details,
|
|
95
|
+
)
|
|
96
|
+
except Exception as exc: # noqa: BLE001 — audit logging must never break the caller
|
|
97
|
+
_log.warning("record_audit: best-effort write failed: %s", exc)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _record_audit_unsafe(
|
|
101
|
+
*,
|
|
102
|
+
tier: str,
|
|
103
|
+
category: str,
|
|
104
|
+
event_type: str,
|
|
105
|
+
actor: str,
|
|
106
|
+
summary: str,
|
|
107
|
+
ts: float | None,
|
|
108
|
+
repo: str | None,
|
|
109
|
+
issue: int | None,
|
|
110
|
+
assignment_id: str | None,
|
|
111
|
+
machine: str | None,
|
|
112
|
+
details: dict[str, Any] | None,
|
|
113
|
+
) -> None:
|
|
114
|
+
"""The actual write. Split out from :func:`record_audit` so the
|
|
115
|
+
try/except wrapper is the ONLY thing between this and the caller —
|
|
116
|
+
keeps the swallow-and-log behavior in one obvious place."""
|
|
117
|
+
conn = get_connection()
|
|
118
|
+
conn.execute(
|
|
119
|
+
"""INSERT INTO audit_log (
|
|
120
|
+
ts, tier, category, event_type, actor,
|
|
121
|
+
repo, issue, assignment_id, machine, summary, details_json
|
|
122
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
123
|
+
(
|
|
124
|
+
ts if ts is not None else time.time(),
|
|
125
|
+
tier,
|
|
126
|
+
category,
|
|
127
|
+
event_type,
|
|
128
|
+
actor,
|
|
129
|
+
repo,
|
|
130
|
+
issue,
|
|
131
|
+
assignment_id,
|
|
132
|
+
machine,
|
|
133
|
+
summary,
|
|
134
|
+
json.dumps(details) if details is not None else None,
|
|
135
|
+
),
|
|
136
|
+
)
|
|
137
|
+
conn.commit()
|
|
138
|
+
_maybe_trim(conn)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _maybe_trim(conn) -> None:
|
|
142
|
+
"""Opportunistic cap: when ``audit.max_rows`` is set (> 0), delete the
|
|
143
|
+
oldest rows past that count after every insert.
|
|
144
|
+
|
|
145
|
+
Default (``max_rows=0``) is unlimited — this is a no-op in the common
|
|
146
|
+
case. Config is re-loaded on every call rather than cached: audit
|
|
147
|
+
writes are not hot-loop-frequency (one per board transition, not per
|
|
148
|
+
daemon tick), so the extra YAML parse is cheap relative to the SQLite
|
|
149
|
+
round-trip it accompanies, and it means a config edit takes effect on
|
|
150
|
+
the next write without a process restart.
|
|
151
|
+
"""
|
|
152
|
+
max_rows = _resolve_max_rows()
|
|
153
|
+
if max_rows <= 0:
|
|
154
|
+
return
|
|
155
|
+
conn.execute(
|
|
156
|
+
"DELETE FROM audit_log WHERE id NOT IN "
|
|
157
|
+
"(SELECT id FROM audit_log ORDER BY id DESC LIMIT ?)",
|
|
158
|
+
(max_rows,),
|
|
159
|
+
)
|
|
160
|
+
conn.commit()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _resolve_max_rows() -> int:
|
|
164
|
+
"""Read ``audit.max_rows`` from coordinator.yml. Returns 0 (unlimited)
|
|
165
|
+
on any failure — a missing/invalid config must not block audit writes,
|
|
166
|
+
let alone the board mutation that triggered them."""
|
|
167
|
+
try:
|
|
168
|
+
from coord.config import load as _load_config # noqa: PLC0415
|
|
169
|
+
|
|
170
|
+
cfg = _load_config()
|
|
171
|
+
return max(0, int(cfg.audit.max_rows))
|
|
172
|
+
except Exception: # noqa: BLE001 — best-effort; unlimited is the safe default
|
|
173
|
+
return 0
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _resolve_level() -> str:
|
|
177
|
+
"""Read ``audit.level`` from coordinator.yml. Returns ``"operational"``
|
|
178
|
+
(the default — capture everything) on any failure, so a missing/invalid
|
|
179
|
+
config never silently suppresses audit rows."""
|
|
180
|
+
try:
|
|
181
|
+
from coord.config import load as _load_config # noqa: PLC0415
|
|
182
|
+
|
|
183
|
+
cfg = _load_config()
|
|
184
|
+
level = cfg.audit.level
|
|
185
|
+
return level if level in _VALID_TIERS else "operational"
|
|
186
|
+
except Exception: # noqa: BLE001 — best-effort; capture-everything is the safe default
|
|
187
|
+
return "operational"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ── Read side (#1037): paginated query over audit_log ──────────────────────
|
|
191
|
+
|
|
192
|
+
_AUDIT_COLUMNS = (
|
|
193
|
+
"id", "ts", "tier", "category", "event_type", "actor",
|
|
194
|
+
"repo", "issue", "assignment_id", "machine", "summary", "details_json",
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _encode_cursor(ts: float, row_id: int) -> str:
|
|
199
|
+
"""Opaque-ish keyset cursor over ``(ts, id)``. Not meant to be parsed by
|
|
200
|
+
callers — just round-tripped through ``cursor`` on the next request."""
|
|
201
|
+
return f"{ts!r}:{row_id}"
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _decode_cursor(cursor: str | None) -> tuple[float, int] | None:
|
|
205
|
+
"""Parse a cursor produced by :func:`_encode_cursor`. Returns ``None``
|
|
206
|
+
for a blank/malformed cursor — callers treat that as "first page" rather
|
|
207
|
+
than raising, since a stale/garbled cursor should degrade gracefully
|
|
208
|
+
(start over), not 400 the whole request."""
|
|
209
|
+
if not cursor:
|
|
210
|
+
return None
|
|
211
|
+
try:
|
|
212
|
+
ts_part, id_part = cursor.rsplit(":", 1)
|
|
213
|
+
return float(ts_part), int(id_part)
|
|
214
|
+
except (ValueError, TypeError):
|
|
215
|
+
return None
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _row_to_entry(row: Any) -> dict[str, Any]:
|
|
219
|
+
entry = {col: row[col] for col in _AUDIT_COLUMNS}
|
|
220
|
+
details_raw = entry.pop("details_json")
|
|
221
|
+
entry["details"] = json.loads(details_raw) if details_raw else None
|
|
222
|
+
return entry
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def query_audit_log(
|
|
226
|
+
*,
|
|
227
|
+
since: float | None = None,
|
|
228
|
+
until: float | None = None,
|
|
229
|
+
event_type: str | None = None,
|
|
230
|
+
category: str | None = None,
|
|
231
|
+
repo: str | None = None,
|
|
232
|
+
issue: int | None = None,
|
|
233
|
+
assignment_id: str | None = None,
|
|
234
|
+
tier: str | None = None,
|
|
235
|
+
limit: int = DEFAULT_LIMIT,
|
|
236
|
+
cursor: str | None = None,
|
|
237
|
+
) -> dict[str, Any]:
|
|
238
|
+
"""Keyset-paginated, newest-first read over ``audit_log``.
|
|
239
|
+
|
|
240
|
+
Ordered by ``(ts, id) DESC`` — a cursor (not ``OFFSET``) carries the last
|
|
241
|
+
row of the previous page, so pagination stays O(page size) as the table
|
|
242
|
+
grows. All filters are optional and AND together. ``limit`` is clamped
|
|
243
|
+
to ``(1, MAX_LIMIT]``, defaulting to ``DEFAULT_LIMIT``.
|
|
244
|
+
|
|
245
|
+
Returns ``{"entries": [...], "next_cursor": str | None, "has_more": bool}``.
|
|
246
|
+
Each entry has ``details_json`` decoded into a ``details`` dict (``None``
|
|
247
|
+
when absent), matching the shape callers actually want on the wire.
|
|
248
|
+
"""
|
|
249
|
+
try:
|
|
250
|
+
limit = int(limit)
|
|
251
|
+
except (TypeError, ValueError):
|
|
252
|
+
limit = DEFAULT_LIMIT
|
|
253
|
+
limit = max(1, min(limit, MAX_LIMIT))
|
|
254
|
+
|
|
255
|
+
clauses: list[str] = []
|
|
256
|
+
params: list[Any] = []
|
|
257
|
+
if since is not None:
|
|
258
|
+
clauses.append("ts >= ?")
|
|
259
|
+
params.append(since)
|
|
260
|
+
if until is not None:
|
|
261
|
+
clauses.append("ts <= ?")
|
|
262
|
+
params.append(until)
|
|
263
|
+
if event_type:
|
|
264
|
+
clauses.append("event_type = ?")
|
|
265
|
+
params.append(event_type)
|
|
266
|
+
if category:
|
|
267
|
+
clauses.append("category = ?")
|
|
268
|
+
params.append(category)
|
|
269
|
+
if repo:
|
|
270
|
+
clauses.append("repo = ?")
|
|
271
|
+
params.append(repo)
|
|
272
|
+
if issue is not None:
|
|
273
|
+
clauses.append("issue = ?")
|
|
274
|
+
params.append(issue)
|
|
275
|
+
if assignment_id:
|
|
276
|
+
clauses.append("assignment_id = ?")
|
|
277
|
+
params.append(assignment_id)
|
|
278
|
+
if tier:
|
|
279
|
+
clauses.append("tier = ?")
|
|
280
|
+
params.append(tier)
|
|
281
|
+
|
|
282
|
+
decoded_cursor = _decode_cursor(cursor)
|
|
283
|
+
if decoded_cursor is not None:
|
|
284
|
+
cur_ts, cur_id = decoded_cursor
|
|
285
|
+
clauses.append("(ts < ? OR (ts = ? AND id < ?))")
|
|
286
|
+
params.extend([cur_ts, cur_ts, cur_id])
|
|
287
|
+
|
|
288
|
+
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
|
289
|
+
conn = get_connection()
|
|
290
|
+
# Fetch one extra row to detect has_more without a second COUNT query.
|
|
291
|
+
rows = conn.execute(
|
|
292
|
+
f"SELECT {', '.join(_AUDIT_COLUMNS)} FROM audit_log {where} "
|
|
293
|
+
"ORDER BY ts DESC, id DESC LIMIT ?",
|
|
294
|
+
(*params, limit + 1),
|
|
295
|
+
).fetchall()
|
|
296
|
+
|
|
297
|
+
has_more = len(rows) > limit
|
|
298
|
+
rows = rows[:limit]
|
|
299
|
+
entries = [_row_to_entry(r) for r in rows]
|
|
300
|
+
next_cursor = _encode_cursor(rows[-1]["ts"], rows[-1]["id"]) if has_more and rows else None
|
|
301
|
+
return {"entries": entries, "next_cursor": next_cursor, "has_more": has_more}
|