@matt82198/aesop 0.1.0-beta.5 → 0.1.0
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.
- package/CHANGELOG.md +158 -5
- package/LICENSE +66 -21
- package/README.md +97 -33
- package/aesop.config.example.json +6 -0
- package/bin/CLAUDE.md +12 -3
- package/bin/cli.js +211 -40
- package/daemons/CLAUDE.md +1 -1
- package/daemons/backup-fleet.sh +209 -51
- package/daemons/run-watchdog.sh +208 -62
- package/dash/dash-extra.mjs +73 -4
- package/dash/watchdog-gui.sh +41 -35
- package/docs/ARCHITECTURE.md +296 -0
- package/docs/CONCEPTS.md +254 -0
- package/docs/CONFIGURE.md +256 -0
- package/docs/FIRST-WAVE.md +276 -0
- package/docs/INSTALL.md +224 -0
- package/docs/README.md +176 -27
- package/docs/autonomous-swe.md +149 -0
- package/docs/reproduce.md +121 -0
- package/hooks/CLAUDE.md +28 -0
- package/hooks/install-waveguard.sh +68 -0
- package/hooks/pre-commit-waveguard.sh +26 -0
- package/hooks/pre-push-policy.sh +253 -15
- package/monitor/CLAUDE.md +3 -3
- package/monitor/collect-signals.mjs +133 -20
- package/package.json +15 -7
- package/scan/CLAUDE.md +30 -0
- package/skills/CLAUDE.md +1 -0
- package/state_store/ingest.py +18 -1
- package/state_store/projections.py +78 -4
- package/state_store/store.py +102 -7
- package/tools/CLAUDE.md +25 -18
- package/tools/alert_bridge.py +14 -10
- package/tools/bench_runner.py +438 -0
- package/tools/buildlog.py +4 -7
- package/tools/ci_merge_wait.py +215 -34
- package/tools/common.py +62 -0
- package/tools/cost_ceiling.py +191 -0
- package/tools/dash.js +102 -0
- package/tools/doctor.js +220 -0
- package/tools/fleet_ledger.py +189 -17
- package/tools/halt.py +172 -0
- package/tools/healthcheck.py +24 -21
- package/tools/heartbeat.py +4 -7
- package/tools/metrics_gate.py +8 -2
- package/tools/orchestrator_status.py +4 -9
- package/tools/reconcile.py +277 -0
- package/tools/rotate_logs.py +152 -62
- package/tools/scanner_selftest.py +28 -3
- package/tools/secret_scan.py +359 -90
- package/tools/self_stats.py +292 -4
- package/tools/status.js +187 -0
- package/tools/verify_agent_inspector.py +289 -0
- package/tools/verify_prboard.py +344 -0
- package/tools/watch.js +49 -0
- package/ui/CLAUDE.md +2 -0
- package/ui/agents.py +332 -33
- package/ui/api/tracker.py +15 -7
- package/ui/collectors.py +125 -32
- package/ui/config.py +22 -2
- package/ui/cost.py +98 -3
- package/ui/csrf.py +2 -1
- package/ui/handler.py +209 -21
- package/ui/sse.py +102 -16
- package/ui/wave_prs.py +230 -0
- package/ui/web/dist/assets/index-0qQYnvMC.js +9 -0
- package/ui/web/dist/assets/index-BdIlFieV.css +1 -0
- package/ui/web/dist/index.html +2 -2
- package/monitor/.signal-state.json +0 -3
- package/monitor/ACTIONS.log +0 -1
- package/monitor/BRIEF.md +0 -24
- package/monitor/SIGNALS.json +0 -54
- package/state_store/__pycache__/__init__.cpython-314.pyc +0 -0
- package/state_store/__pycache__/api.cpython-314.pyc +0 -0
- package/state_store/__pycache__/export.cpython-314.pyc +0 -0
- package/state_store/__pycache__/ingest.cpython-314.pyc +0 -0
- package/state_store/__pycache__/projections.cpython-314.pyc +0 -0
- package/state_store/__pycache__/store.cpython-314.pyc +0 -0
- package/tools/__pycache__/alert_bridge.cpython-314.pyc +0 -0
- package/tools/__pycache__/buildlog.cpython-314.pyc +0 -0
- package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
- package/tools/__pycache__/ensure_state.cpython-314.pyc +0 -0
- package/tools/__pycache__/eod_sweep.cpython-314.pyc +0 -0
- package/tools/__pycache__/fleet_ledger.cpython-314.pyc +0 -0
- package/tools/__pycache__/fleet_prompt_extractor.cpython-314.pyc +0 -0
- package/tools/__pycache__/healthcheck.cpython-314.pyc +0 -0
- package/tools/__pycache__/heartbeat.cpython-314.pyc +0 -0
- package/tools/__pycache__/inbox_drain.cpython-314.pyc +0 -0
- package/tools/__pycache__/launch_tui.cpython-314.pyc +0 -0
- package/tools/__pycache__/metrics_gate.cpython-314.pyc +0 -0
- package/tools/__pycache__/orchestrator_status.cpython-314.pyc +0 -0
- package/tools/__pycache__/power_selftest.cpython-314.pyc +0 -0
- package/tools/__pycache__/prepublish_scan.cpython-314.pyc +0 -0
- package/tools/__pycache__/rotate_logs.cpython-314.pyc +0 -0
- package/tools/__pycache__/scanner_selftest.cpython-314.pyc +0 -0
- package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
- package/tools/__pycache__/self_stats.cpython-314.pyc +0 -0
- package/tools/__pycache__/session_usage_summary.cpython-314.pyc +0 -0
- package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
- package/tools/__pycache__/transcript_replay.cpython-314.pyc +0 -0
- package/tools/__pycache__/transcript_timeline.cpython-314.pyc +0 -0
- package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
- package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
- package/ui/__pycache__/agents.cpython-314.pyc +0 -0
- package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
- package/ui/__pycache__/config.cpython-314.pyc +0 -0
- package/ui/__pycache__/cost.cpython-314.pyc +0 -0
- package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
- package/ui/__pycache__/handler.cpython-314.pyc +0 -0
- package/ui/__pycache__/render.cpython-314.pyc +0 -0
- package/ui/__pycache__/serve.cpython-314.pyc +0 -0
- package/ui/__pycache__/sse.cpython-314.pyc +0 -0
- package/ui/api/__pycache__/__init__.cpython-314.pyc +0 -0
- package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
- package/ui/api/__pycache__/tracker.cpython-314.pyc +0 -0
- package/ui/web/.gitattributes +0 -13
- package/ui/web/dist/assets/index-2LZDQirC.js +0 -9
- package/ui/web/dist/assets/index-D4M1qyOv.css +0 -1
- package/ui/web/index.html +0 -13
- package/ui/web/package-lock.json +0 -2225
- package/ui/web/package.json +0 -26
- package/ui/web/src/App.test.tsx +0 -74
- package/ui/web/src/App.tsx +0 -142
- package/ui/web/src/CONTRIBUTING-UI.md +0 -49
- package/ui/web/src/components/AgentRow.css +0 -187
- package/ui/web/src/components/AgentRow.test.tsx +0 -209
- package/ui/web/src/components/AgentRow.tsx +0 -207
- package/ui/web/src/components/AgentsPanel.css +0 -108
- package/ui/web/src/components/AgentsPanel.test.tsx +0 -41
- package/ui/web/src/components/AgentsPanel.tsx +0 -58
- package/ui/web/src/components/AlertsPanel.css +0 -88
- package/ui/web/src/components/AlertsPanel.test.tsx +0 -51
- package/ui/web/src/components/AlertsPanel.tsx +0 -67
- package/ui/web/src/components/BacklogPanel.test.tsx +0 -126
- package/ui/web/src/components/BacklogPanel.tsx +0 -122
- package/ui/web/src/components/CostChart.css +0 -110
- package/ui/web/src/components/CostChart.test.tsx +0 -144
- package/ui/web/src/components/CostChart.tsx +0 -152
- package/ui/web/src/components/CostTable.css +0 -93
- package/ui/web/src/components/CostTable.test.tsx +0 -165
- package/ui/web/src/components/CostTable.tsx +0 -94
- package/ui/web/src/components/EventsFeed.css +0 -68
- package/ui/web/src/components/EventsFeed.test.tsx +0 -36
- package/ui/web/src/components/EventsFeed.tsx +0 -31
- package/ui/web/src/components/HealthHeader.css +0 -137
- package/ui/web/src/components/HealthHeader.test.tsx +0 -278
- package/ui/web/src/components/HealthHeader.tsx +0 -281
- package/ui/web/src/components/InboxForm.css +0 -135
- package/ui/web/src/components/InboxForm.test.tsx +0 -208
- package/ui/web/src/components/InboxForm.tsx +0 -116
- package/ui/web/src/components/MessagesTail.module.css +0 -144
- package/ui/web/src/components/MessagesTail.test.tsx +0 -176
- package/ui/web/src/components/MessagesTail.tsx +0 -94
- package/ui/web/src/components/ReposPanel.css +0 -90
- package/ui/web/src/components/ReposPanel.test.tsx +0 -45
- package/ui/web/src/components/ReposPanel.tsx +0 -67
- package/ui/web/src/components/Scorecard.css +0 -106
- package/ui/web/src/components/Scorecard.test.tsx +0 -117
- package/ui/web/src/components/Scorecard.tsx +0 -85
- package/ui/web/src/components/Timeline.module.css +0 -151
- package/ui/web/src/components/Timeline.test.tsx +0 -215
- package/ui/web/src/components/Timeline.tsx +0 -99
- package/ui/web/src/components/TrackerBoard.test.tsx +0 -121
- package/ui/web/src/components/TrackerBoard.tsx +0 -107
- package/ui/web/src/components/TrackerCard.test.tsx +0 -180
- package/ui/web/src/components/TrackerCard.tsx +0 -160
- package/ui/web/src/components/TrackerForm.test.tsx +0 -189
- package/ui/web/src/components/TrackerForm.tsx +0 -144
- package/ui/web/src/lib/api.ts +0 -218
- package/ui/web/src/lib/format.test.ts +0 -89
- package/ui/web/src/lib/format.ts +0 -103
- package/ui/web/src/lib/sanitizeUrl.test.ts +0 -84
- package/ui/web/src/lib/sanitizeUrl.ts +0 -38
- package/ui/web/src/lib/types.ts +0 -230
- package/ui/web/src/lib/useHashRoute.test.ts +0 -60
- package/ui/web/src/lib/useHashRoute.ts +0 -23
- package/ui/web/src/lib/useSSE.ts +0 -175
- package/ui/web/src/main.tsx +0 -10
- package/ui/web/src/styles/global.css +0 -179
- package/ui/web/src/styles/theme.css +0 -184
- package/ui/web/src/styles/work.css +0 -572
- package/ui/web/src/test/fixtures.ts +0 -385
- package/ui/web/src/test/setup.ts +0 -49
- package/ui/web/src/views/Activity.module.css +0 -43
- package/ui/web/src/views/Activity.test.tsx +0 -89
- package/ui/web/src/views/Activity.tsx +0 -31
- package/ui/web/src/views/Cost.css +0 -87
- package/ui/web/src/views/Cost.test.tsx +0 -142
- package/ui/web/src/views/Cost.tsx +0 -54
- package/ui/web/src/views/Overview.css +0 -51
- package/ui/web/src/views/Overview.test.tsx +0 -76
- package/ui/web/src/views/Overview.tsx +0 -46
- package/ui/web/src/views/Work.test.tsx +0 -82
- package/ui/web/src/views/Work.tsx +0 -79
- package/ui/web/src/vite-env.d.ts +0 -10
- package/ui/web/tsconfig.json +0 -22
- package/ui/web/vite.config.ts +0 -25
- package/ui/web/vitest.config.ts +0 -12
package/tools/healthcheck.py
CHANGED
|
@@ -24,6 +24,11 @@ from datetime import datetime, timezone
|
|
|
24
24
|
from pathlib import Path
|
|
25
25
|
from time import time
|
|
26
26
|
|
|
27
|
+
try:
|
|
28
|
+
from common import check_heartbeat_staleness
|
|
29
|
+
except ImportError:
|
|
30
|
+
from tools.common import check_heartbeat_staleness
|
|
31
|
+
|
|
27
32
|
# Import UI config for state path resolution
|
|
28
33
|
UI_DIR = Path(__file__).parent.parent / "ui"
|
|
29
34
|
if str(UI_DIR) not in sys.path:
|
|
@@ -94,31 +99,29 @@ def _check_heartbeat(name, heartbeat_file, threshold=300):
|
|
|
94
99
|
Returns:
|
|
95
100
|
tuple (severity, message) or None if all OK
|
|
96
101
|
"""
|
|
97
|
-
|
|
98
|
-
return ("YELLOW", f"no {name} heartbeat file")
|
|
102
|
+
is_stale, age_seconds, info = check_heartbeat_staleness(heartbeat_file, threshold)
|
|
99
103
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
if not content:
|
|
103
|
-
return ("YELLOW", f"{name} heartbeat empty")
|
|
104
|
+
if not is_stale:
|
|
105
|
+
return None
|
|
104
106
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
107
|
+
# Stale: determine severity and message
|
|
108
|
+
if age_seconds == 0:
|
|
109
|
+
# File missing/empty/unreadable - use generic message
|
|
110
|
+
if "missing" in info.lower():
|
|
111
|
+
return ("YELLOW", f"no {name} heartbeat file")
|
|
112
|
+
elif "empty" in info.lower():
|
|
113
|
+
return ("YELLOW", f"{name} heartbeat empty")
|
|
114
|
+
else:
|
|
108
115
|
return ("YELLOW", f"{name} heartbeat unparseable")
|
|
109
116
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
return None
|
|
120
|
-
except Exception as e:
|
|
121
|
-
return ("YELLOW", f"{name} heartbeat read error: {e}")
|
|
117
|
+
# File exists but is stale
|
|
118
|
+
if age_seconds >= threshold * 2:
|
|
119
|
+
# Watchdog dead while orchestrator might be active
|
|
120
|
+
if name == "watchdog" and age_seconds >= 600:
|
|
121
|
+
return ("RED", f"watchdog dead ({age_seconds}s)")
|
|
122
|
+
return ("YELLOW", f"{name} stale ({age_seconds}s > {threshold}s threshold)")
|
|
123
|
+
else:
|
|
124
|
+
return ("YELLOW", f"{name} stale ({age_seconds}s)")
|
|
122
125
|
|
|
123
126
|
|
|
124
127
|
def _check_alerts(alerts_file):
|
package/tools/heartbeat.py
CHANGED
|
@@ -20,13 +20,10 @@ import time
|
|
|
20
20
|
from pathlib import Path
|
|
21
21
|
import argparse
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
return Path(os.environ["AESOP_STATE_ROOT"])
|
|
28
|
-
# Default to ./state (relative to cwd)
|
|
29
|
-
return Path.cwd() / "state"
|
|
23
|
+
try:
|
|
24
|
+
from common import get_state_dir
|
|
25
|
+
except ImportError:
|
|
26
|
+
from tools.common import get_state_dir
|
|
30
27
|
|
|
31
28
|
|
|
32
29
|
def beat(name, status=None, state_dir=None, brain=False):
|
package/tools/metrics_gate.py
CHANGED
|
@@ -54,9 +54,15 @@ class MetricsGate:
|
|
|
54
54
|
VERIFICATION_PATTERN = re.compile(r'<!--\s*metrics-verified:\s*[^-]+\s*-->')
|
|
55
55
|
|
|
56
56
|
def __init__(self, diff_range: str = "origin/main...HEAD"):
|
|
57
|
-
"""Initialize with a git diff range.
|
|
57
|
+
"""Initialize with a git diff range.
|
|
58
|
+
|
|
59
|
+
The gate scans the git repository at the current working directory
|
|
60
|
+
(like git itself), not the directory the script happens to live in.
|
|
61
|
+
In CI it is invoked from the repo root, so behaviour is unchanged;
|
|
62
|
+
this also makes the gate exercisable against any repo under test.
|
|
63
|
+
"""
|
|
58
64
|
self.diff_range = diff_range
|
|
59
|
-
self.repo_root = Path
|
|
65
|
+
self.repo_root = Path.cwd()
|
|
60
66
|
|
|
61
67
|
def get_diff_lines(self) -> List[Tuple[str, str]]:
|
|
62
68
|
"""Get lines added in diff. Returns list of (file, line_content)."""
|
|
@@ -15,15 +15,10 @@ import sys
|
|
|
15
15
|
from datetime import datetime, timezone
|
|
16
16
|
from pathlib import Path
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
if state_root:
|
|
23
|
-
return Path(state_root)
|
|
24
|
-
|
|
25
|
-
aesop_root = os.getenv("AESOP_ROOT", str(Path.home() / "aesop"))
|
|
26
|
-
return Path(aesop_root) / "state"
|
|
18
|
+
try:
|
|
19
|
+
from common import get_state_dir
|
|
20
|
+
except ImportError:
|
|
21
|
+
from tools.common import get_state_dir
|
|
27
22
|
|
|
28
23
|
|
|
29
24
|
def set_status(activity, phase, id=None, role=None):
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
reconcile.py — drift detection between STATE.md (git) and state_store (SQLite).
|
|
4
|
+
|
|
5
|
+
## Why this exists
|
|
6
|
+
|
|
7
|
+
Critique: "SQLite state_store and git-committed STATE.md drift with NO reconcile
|
|
8
|
+
logic." This tool is the conservative first step: detect + report drift by
|
|
9
|
+
default; fix it only with an explicit ``--resolve``.
|
|
10
|
+
|
|
11
|
+
## What was actually investigated (read STATE.md + state_store/*.py end to end)
|
|
12
|
+
|
|
13
|
+
STATE.md (see ensure_state.py's own template) is unstructured orchestrator
|
|
14
|
+
narrative: Intent / locked decisions / current Phase / NEXT STEPS, written and
|
|
15
|
+
owned by the orchestrator (single-writer, per this project's CLAUDE.md Cardinal
|
|
16
|
+
Rule #7 — "Single-writer: MEMORY.md (keeper), STATE.md (orchestrator)").
|
|
17
|
+
|
|
18
|
+
state_store/ (store.py + api.py + projections.py) is an event-sourced SQLite
|
|
19
|
+
log. Today it registers exactly ONE projection: ``tracker`` — a UI Kanban
|
|
20
|
+
board of backlog items (``id``/``title``/``lane``/``status``/``tags``/
|
|
21
|
+
``pr_link``/``completed_at``). That projection is a DIFFERENT domain from
|
|
22
|
+
STATE.md: it was one-time-migrated from AUDIT-BACKLOG.md
|
|
23
|
+
(``ui/collectors.py:migrate_tracker_from_backlog``), not from STATE.md, and is
|
|
24
|
+
independently mutated via the UI CRUD endpoints thereafter. There is no
|
|
25
|
+
shared id, key, or semantic concept between STATE.md's prose and a tracker
|
|
26
|
+
item. Reconciling STATE.md against ``tracker`` would mean inventing a mapping
|
|
27
|
+
that does not exist in the codebase's actual design — the task instructions
|
|
28
|
+
explicitly warn against exactly that ("do not invent overlap").
|
|
29
|
+
|
|
30
|
+
**Honest finding: STATE.md and state_store are almost entirely DISJOINT.**
|
|
31
|
+
|
|
32
|
+
The ONE field that genuinely is meant to be "the same fact, told twice" is the
|
|
33
|
+
orchestrator's **current phase/wave identifier** — STATE.md carries it today
|
|
34
|
+
in a structured, reliably-parseable heading (``## Phase: `wave-N-name` ``);
|
|
35
|
+
state_store has no field for it yet (it predates the "additive prototype... a
|
|
36
|
+
later dual-read cutover" state_store/CLAUDE.md describes). Rather than
|
|
37
|
+
pretend a comparable field exists where none does, this tool defines that
|
|
38
|
+
field explicitly — a ``meta`` event stream, ``phase_set`` events, payload
|
|
39
|
+
``{"phase": <str>}`` — read directly off the generic ``EventStore`` (no
|
|
40
|
+
change to state_store/ itself; any stream name is legal there). Until
|
|
41
|
+
something else populates that stream, real runs will simply show "state_store
|
|
42
|
+
has no phase recorded" every time, which is itself an honest, useful signal:
|
|
43
|
+
it names precisely the gap the critique is pointing at, rather than
|
|
44
|
+
manufacturing a false "in sync".
|
|
45
|
+
|
|
46
|
+
## Authority
|
|
47
|
+
|
|
48
|
+
- ``phase`` is git-authoritative: STATE.md is the durable, human-reviewed,
|
|
49
|
+
single-writer checkpoint (Cardinal Rule #7); state_store's copy is a mirror
|
|
50
|
+
for read-side consumers (dashboards, MCP, etc.) that would rather not parse
|
|
51
|
+
markdown. ``--resolve`` therefore only ever WRITES to state_store (a new
|
|
52
|
+
``phase_set`` event) and NEVER writes to STATE.md — the tool must not
|
|
53
|
+
become a second writer of the orchestrator's single-writer file.
|
|
54
|
+
- The field registry (``FIELDS``) and ``decide_resolution`` are generic and
|
|
55
|
+
support a `state_store`-authoritative field the same way, for when
|
|
56
|
+
state_store gains its own independently event-derived facts (e.g. real
|
|
57
|
+
wave-tagged item counts) that STATE.md would want mirrored back — see
|
|
58
|
+
``test_reconcile.py``'s ``DecideResolutionTest`` for that direction
|
|
59
|
+
exercised in isolation, since no current field actually uses it.
|
|
60
|
+
|
|
61
|
+
## Usage
|
|
62
|
+
|
|
63
|
+
python tools/reconcile.py --state-md STATE.md --db state/events.db
|
|
64
|
+
python tools/reconcile.py --state-md STATE.md --db state/events.db --resolve
|
|
65
|
+
python tools/reconcile.py --state-md STATE.md --db state/events.db --json
|
|
66
|
+
|
|
67
|
+
Exit codes: 0 = no drift (or drift cleanly resolved), 1 = drift detected and
|
|
68
|
+
NOT resolved (report mode), 2 = usage/file error.
|
|
69
|
+
"""
|
|
70
|
+
from __future__ import annotations
|
|
71
|
+
|
|
72
|
+
import argparse
|
|
73
|
+
import json
|
|
74
|
+
import os
|
|
75
|
+
import re
|
|
76
|
+
import sys
|
|
77
|
+
from pathlib import Path
|
|
78
|
+
|
|
79
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
80
|
+
if str(ROOT) not in sys.path:
|
|
81
|
+
sys.path.insert(0, str(ROOT))
|
|
82
|
+
|
|
83
|
+
from state_store.store import EventStore # noqa: E402
|
|
84
|
+
|
|
85
|
+
PHASE_RE = re.compile(r"^##\s*Phase:\s*`([^`]+)`", re.MULTILINE)
|
|
86
|
+
|
|
87
|
+
META_STREAM = "meta"
|
|
88
|
+
PHASE_EVENT_TYPE = "phase_set"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
# Readers (read-only; never create/mutate anything)
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
def read_git_phase(state_md_path: str) -> str | None:
|
|
96
|
+
"""Parse the current phase identifier out of STATE.md's ``## Phase:`` heading.
|
|
97
|
+
|
|
98
|
+
Returns None if the file has no such heading (or is missing entirely —
|
|
99
|
+
callers that need a hard error on a missing file should stat() first).
|
|
100
|
+
"""
|
|
101
|
+
path = Path(state_md_path)
|
|
102
|
+
if not path.exists():
|
|
103
|
+
return None
|
|
104
|
+
content = path.read_text(encoding="utf-8")
|
|
105
|
+
match = PHASE_RE.search(content)
|
|
106
|
+
return match.group(1) if match else None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def read_store_phase(db_path: str) -> str | None:
|
|
110
|
+
"""Read the most recent ``phase_set`` event's ``phase`` payload from state_store.
|
|
111
|
+
|
|
112
|
+
Deliberately avoids ever constructing an EventStore against a
|
|
113
|
+
non-existent db_path in report mode: EventStore.__init__ creates the
|
|
114
|
+
sqlite file + tables as a side effect of merely connecting, which would
|
|
115
|
+
make a read-only "detect" invocation leave a footprint on disk. If the
|
|
116
|
+
file does not exist yet, there is by definition no phase recorded there.
|
|
117
|
+
"""
|
|
118
|
+
if not db_path or not os.path.exists(db_path):
|
|
119
|
+
return None
|
|
120
|
+
store = EventStore(db_path)
|
|
121
|
+
events = store.read(META_STREAM)
|
|
122
|
+
phase = None
|
|
123
|
+
for ev in events:
|
|
124
|
+
if ev.get("type") == PHASE_EVENT_TYPE:
|
|
125
|
+
payload = ev.get("payload") or {}
|
|
126
|
+
if "phase" in payload:
|
|
127
|
+
phase = payload["phase"]
|
|
128
|
+
return phase
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def write_store_phase(db_path: str, phase: str, actor: str = "reconcile") -> int:
|
|
132
|
+
"""Append a phase_set event to state_store's meta stream. Returns new version.
|
|
133
|
+
|
|
134
|
+
This is the ONLY write path in this module, and it only ever targets
|
|
135
|
+
state_store (SQLite) — STATE.md (git) is never written by this tool.
|
|
136
|
+
"""
|
|
137
|
+
store = EventStore(db_path)
|
|
138
|
+
return store.append(META_STREAM, PHASE_EVENT_TYPE, {"phase": phase}, actor)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ---------------------------------------------------------------------------
|
|
142
|
+
# Field registry — generic so a future state_store-authoritative field can be
|
|
143
|
+
# added without restructuring the detect/resolve flow.
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
FIELDS = {
|
|
147
|
+
"phase": {
|
|
148
|
+
"authority": "git",
|
|
149
|
+
"read_git": read_git_phase,
|
|
150
|
+
"read_store": read_store_phase,
|
|
151
|
+
},
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def decide_resolution(git_value, store_value, authority: str):
|
|
156
|
+
"""Pure decision: given both current values and which side is authoritative,
|
|
157
|
+
return (drift: bool, target: 'git'|'store'|None, value) where ``target`` is
|
|
158
|
+
which side WOULD need writing to clear the drift (None if no drift, or if
|
|
159
|
+
already in agreement).
|
|
160
|
+
|
|
161
|
+
``authority`` must be 'git' or 'store'. This function performs no I/O —
|
|
162
|
+
it only decides; callers apply the write.
|
|
163
|
+
"""
|
|
164
|
+
if authority not in ("git", "store"):
|
|
165
|
+
raise ValueError(f"unknown authority: {authority!r}")
|
|
166
|
+
|
|
167
|
+
drift = git_value != store_value
|
|
168
|
+
if not drift:
|
|
169
|
+
return (False, None, git_value)
|
|
170
|
+
|
|
171
|
+
if authority == "git":
|
|
172
|
+
return (True, "store", git_value)
|
|
173
|
+
return (True, "git", store_value)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# ---------------------------------------------------------------------------
|
|
177
|
+
# Detect / resolve over the whole registry
|
|
178
|
+
# ---------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
def detect_drift(state_md_path: str, db_path: str) -> dict:
|
|
181
|
+
"""Read both sources for every registered field; report drift per field.
|
|
182
|
+
|
|
183
|
+
Read-only: never creates or mutates STATE.md or the state_store db.
|
|
184
|
+
"""
|
|
185
|
+
results = []
|
|
186
|
+
any_drift = False
|
|
187
|
+
for name, spec in FIELDS.items():
|
|
188
|
+
git_value = spec["read_git"](state_md_path)
|
|
189
|
+
store_value = spec["read_store"](db_path)
|
|
190
|
+
drift, target, _value = decide_resolution(git_value, store_value, spec["authority"])
|
|
191
|
+
any_drift = any_drift or drift
|
|
192
|
+
results.append({
|
|
193
|
+
"field": name,
|
|
194
|
+
"authority": spec["authority"],
|
|
195
|
+
"git_value": git_value,
|
|
196
|
+
"store_value": store_value,
|
|
197
|
+
"drift": drift,
|
|
198
|
+
"fix_target": target,
|
|
199
|
+
})
|
|
200
|
+
return {"drift": any_drift, "fields": results}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def resolve_drift(state_md_path: str, db_path: str) -> dict:
|
|
204
|
+
"""Detect drift, then apply the authoritative value to the non-authoritative
|
|
205
|
+
side for every drifted field. Idempotent: fields already in agreement are
|
|
206
|
+
left untouched (no event appended). Only ever writes to state_store.
|
|
207
|
+
|
|
208
|
+
A field whose authority is 'git' but is drifted is fixed by writing to
|
|
209
|
+
state_store. A field whose authority is 'store' but is drifted would
|
|
210
|
+
require writing to STATE.md — this tool refuses to do that (STATE.md is
|
|
211
|
+
single-writer/orchestrator-owned) and instead reports it unresolved with
|
|
212
|
+
a clear reason, rather than silently corrupting the git checkpoint.
|
|
213
|
+
"""
|
|
214
|
+
report = detect_drift(state_md_path, db_path)
|
|
215
|
+
for entry in report["fields"]:
|
|
216
|
+
if not entry["drift"]:
|
|
217
|
+
entry["action"] = "noop"
|
|
218
|
+
continue
|
|
219
|
+
if entry["fix_target"] == "store" and entry["field"] == "phase":
|
|
220
|
+
write_store_phase(db_path, entry["git_value"])
|
|
221
|
+
entry["action"] = "resolved"
|
|
222
|
+
entry["store_value"] = entry["git_value"]
|
|
223
|
+
elif entry["fix_target"] == "git":
|
|
224
|
+
entry["action"] = "refused"
|
|
225
|
+
entry["reason"] = "STATE.md is single-writer (orchestrator); reconcile.py never writes it"
|
|
226
|
+
else:
|
|
227
|
+
entry["action"] = "unsupported"
|
|
228
|
+
report["drift"] = any(e["drift"] and e.get("action") not in ("resolved",) for e in report["fields"])
|
|
229
|
+
return report
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# ---------------------------------------------------------------------------
|
|
233
|
+
# CLI
|
|
234
|
+
# ---------------------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
def _format_human(report: dict) -> str:
|
|
237
|
+
lines = []
|
|
238
|
+
for entry in report["fields"]:
|
|
239
|
+
status = "DRIFT" if entry["drift"] else "OK"
|
|
240
|
+
line = (
|
|
241
|
+
f"[{status}] {entry['field']} (authority={entry['authority']}): "
|
|
242
|
+
f"git={entry['git_value']!r} store={entry['store_value']!r}"
|
|
243
|
+
)
|
|
244
|
+
if "action" in entry:
|
|
245
|
+
line += f" action={entry['action']}"
|
|
246
|
+
lines.append(line)
|
|
247
|
+
lines.append("DRIFT DETECTED" if report["drift"] else "no drift")
|
|
248
|
+
return "\n".join(lines)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def main(argv=None) -> int:
|
|
252
|
+
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
253
|
+
parser.add_argument("--state-md", default="STATE.md", help="Path to STATE.md (default: ./STATE.md)")
|
|
254
|
+
parser.add_argument("--db", default="state/events.db", help="Path to state_store SQLite db")
|
|
255
|
+
parser.add_argument("--resolve", action="store_true", help="Fix drift by writing the authoritative value to the non-authoritative side (opt-in; default is detect+report only)")
|
|
256
|
+
parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON instead of human text")
|
|
257
|
+
args = parser.parse_args(argv)
|
|
258
|
+
|
|
259
|
+
if not os.path.exists(args.state_md):
|
|
260
|
+
print(f"error: STATE.md not found at {args.state_md}", file=sys.stderr)
|
|
261
|
+
return 2
|
|
262
|
+
|
|
263
|
+
if args.resolve:
|
|
264
|
+
report = resolve_drift(args.state_md, args.db)
|
|
265
|
+
else:
|
|
266
|
+
report = detect_drift(args.state_md, args.db)
|
|
267
|
+
|
|
268
|
+
if args.json:
|
|
269
|
+
print(json.dumps(report, indent=2))
|
|
270
|
+
else:
|
|
271
|
+
print(_format_human(report))
|
|
272
|
+
|
|
273
|
+
return 1 if report["drift"] else 0
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
if __name__ == "__main__":
|
|
277
|
+
raise SystemExit(main())
|
package/tools/rotate_logs.py
CHANGED
|
@@ -5,6 +5,11 @@ Rotates log files by moving the oldest content to an archive file when the origi
|
|
|
5
5
|
exceeds configured thresholds (--max-lines or --max-bytes). Preserves newest lines
|
|
6
6
|
in the original, ensures no data loss (archive + original == original content).
|
|
7
7
|
|
|
8
|
+
Uses advisory file locking to serialize concurrent rotations, and detects/preserves
|
|
9
|
+
external O_APPEND writes (from non-locking writers) that occur during the rotation
|
|
10
|
+
window. This ensures atomicity: no lines are lost even when external processes
|
|
11
|
+
append concurrently.
|
|
12
|
+
|
|
8
13
|
Exit codes:
|
|
9
14
|
0: Success (no rotation needed or rotation completed)
|
|
10
15
|
1: Error (invalid args, I/O failure, etc.)
|
|
@@ -17,6 +22,17 @@ import sys
|
|
|
17
22
|
from datetime import datetime, timezone
|
|
18
23
|
from pathlib import Path
|
|
19
24
|
|
|
25
|
+
# Platform-specific locking imports
|
|
26
|
+
if sys.platform == 'win32':
|
|
27
|
+
import msvcrt
|
|
28
|
+
HAS_FCNTL = False
|
|
29
|
+
else:
|
|
30
|
+
try:
|
|
31
|
+
import fcntl
|
|
32
|
+
HAS_FCNTL = True
|
|
33
|
+
except ImportError:
|
|
34
|
+
HAS_FCNTL = False
|
|
35
|
+
|
|
20
36
|
|
|
21
37
|
def count_lines(filepath):
|
|
22
38
|
"""Count lines in a file."""
|
|
@@ -53,6 +69,34 @@ def write_lines(filepath, lines):
|
|
|
53
69
|
raise IOError(f"Failed to write {filepath}: {e}")
|
|
54
70
|
|
|
55
71
|
|
|
72
|
+
def acquire_lock(fd):
|
|
73
|
+
"""Acquire exclusive lock on file descriptor (cross-platform)."""
|
|
74
|
+
if HAS_FCNTL:
|
|
75
|
+
try:
|
|
76
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
77
|
+
except (AttributeError, TypeError):
|
|
78
|
+
pass
|
|
79
|
+
elif sys.platform == 'win32':
|
|
80
|
+
try:
|
|
81
|
+
msvcrt.locking(fd, msvcrt.LK_LOCK, 1)
|
|
82
|
+
except (OSError, IOError):
|
|
83
|
+
pass # Locking may not be supported on all filesystems
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def release_lock(fd):
|
|
87
|
+
"""Release exclusive lock on file descriptor (cross-platform)."""
|
|
88
|
+
if HAS_FCNTL:
|
|
89
|
+
try:
|
|
90
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
91
|
+
except (AttributeError, TypeError):
|
|
92
|
+
pass
|
|
93
|
+
elif sys.platform == 'win32':
|
|
94
|
+
try:
|
|
95
|
+
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
|
|
96
|
+
except (OSError, IOError):
|
|
97
|
+
pass
|
|
98
|
+
|
|
99
|
+
|
|
56
100
|
def needs_rotation(filepath, max_lines, max_bytes):
|
|
57
101
|
"""Check if file exceeds any threshold."""
|
|
58
102
|
current_lines = count_lines(filepath)
|
|
@@ -66,7 +110,21 @@ def needs_rotation(filepath, max_lines, max_bytes):
|
|
|
66
110
|
|
|
67
111
|
|
|
68
112
|
def rotate_log(logfile, max_lines, max_bytes, archive_dir, check_only=False):
|
|
69
|
-
"""Rotate log file by archiving oldest lines.
|
|
113
|
+
"""Rotate log file by archiving oldest lines atomically.
|
|
114
|
+
|
|
115
|
+
Uses exclusive file locking to serialize rotations against other lock-takers.
|
|
116
|
+
However, this is an ADVISORY lock: it only protects against other processes
|
|
117
|
+
that voluntarily take the lock. External writers (e.g., shell scripts using >>
|
|
118
|
+
or tee -a) that do NOT take the lock are NOT protected by this mechanism.
|
|
119
|
+
|
|
120
|
+
To prevent data loss from non-locking O_APPEND writers:
|
|
121
|
+
- Lines appended between readlines() and truncate() are detected via file size
|
|
122
|
+
comparison and re-read after truncate+write to prevent loss.
|
|
123
|
+
- This guarantees atomicity: archive + original == original content.
|
|
124
|
+
|
|
125
|
+
NOTE: The advisory lock serializes rotations against each other, but race
|
|
126
|
+
conditions with non-locking O_APPEND writers remain possible. For complete
|
|
127
|
+
safety, all writers (including external processes) must cooperate on locking.
|
|
70
128
|
|
|
71
129
|
Args:
|
|
72
130
|
logfile: Path to log file to rotate.
|
|
@@ -104,69 +162,101 @@ def rotate_log(logfile, max_lines, max_bytes, archive_dir, check_only=False):
|
|
|
104
162
|
file=sys.stderr)
|
|
105
163
|
return 1
|
|
106
164
|
|
|
107
|
-
#
|
|
165
|
+
# Perform rotation with exclusive lock to prevent concurrent write races
|
|
108
166
|
try:
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
167
|
+
with open(str(logfile), 'r+') as f:
|
|
168
|
+
# Acquire exclusive lock to guard read-compute-write sequence
|
|
169
|
+
acquire_lock(f.fileno())
|
|
170
|
+
try:
|
|
171
|
+
# Read all lines while locked
|
|
172
|
+
lines = f.readlines()
|
|
173
|
+
|
|
174
|
+
if not lines:
|
|
175
|
+
return 0
|
|
176
|
+
|
|
177
|
+
# Determine split point based on which threshold was exceeded
|
|
178
|
+
current_lines = len(lines)
|
|
179
|
+
current_bytes = sum(len(line.encode('utf-8')) for line in lines)
|
|
180
|
+
|
|
181
|
+
keep_count = current_lines // 2 # Default: keep ~half
|
|
182
|
+
|
|
183
|
+
# If max_lines exceeded, keep lines just under threshold
|
|
184
|
+
if max_lines and current_lines > max_lines:
|
|
185
|
+
keep_count = min(keep_count, max_lines)
|
|
186
|
+
|
|
187
|
+
# If max_bytes exceeded, calculate how many lines fit under threshold
|
|
188
|
+
if max_bytes and current_bytes > max_bytes:
|
|
189
|
+
cumulative_bytes = 0
|
|
190
|
+
bytes_keep_count = 0
|
|
191
|
+
# Count from newest (end) backward
|
|
192
|
+
for i in range(len(lines) - 1, -1, -1):
|
|
193
|
+
line_bytes = len(lines[i].encode('utf-8'))
|
|
194
|
+
if cumulative_bytes + line_bytes <= max_bytes:
|
|
195
|
+
cumulative_bytes += line_bytes
|
|
196
|
+
bytes_keep_count += 1
|
|
197
|
+
else:
|
|
198
|
+
break
|
|
199
|
+
# Use the more conservative count
|
|
200
|
+
keep_count = min(keep_count, bytes_keep_count)
|
|
201
|
+
|
|
202
|
+
# Guard: ensure we keep at least 1 line, archive the rest
|
|
203
|
+
if keep_count <= 0:
|
|
204
|
+
# If calculated keep_count is 0 or negative, keep at least 1 line
|
|
205
|
+
keep_count = 1
|
|
206
|
+
|
|
207
|
+
# Split lines
|
|
208
|
+
archive_lines = lines[:-keep_count] if keep_count < len(lines) else []
|
|
209
|
+
remaining_lines = lines[-keep_count:] if keep_count > 0 else []
|
|
210
|
+
|
|
211
|
+
if not archive_lines:
|
|
212
|
+
# Nothing to archive
|
|
213
|
+
return 0
|
|
214
|
+
|
|
215
|
+
# Generate archive filename with UTC timestamp
|
|
216
|
+
utc_now = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
|
|
217
|
+
archive_filename = f"{logfile.stem}.{utc_now}.log"
|
|
218
|
+
archive_file = archive_path / archive_filename
|
|
219
|
+
|
|
220
|
+
# Write archive file
|
|
221
|
+
try:
|
|
222
|
+
write_lines(str(archive_file), archive_lines)
|
|
223
|
+
except IOError as e:
|
|
224
|
+
print(f"ERROR: {e}", file=sys.stderr)
|
|
225
|
+
return 1
|
|
226
|
+
|
|
227
|
+
# MITIGATION for external O_APPEND writers:
|
|
228
|
+
# Before truncating, check if the file has grown since we initially read it.
|
|
229
|
+
# If external (non-locking) O_APPEND writers added content while we were
|
|
230
|
+
# computing, re-read and preserve those appends.
|
|
231
|
+
original_line_count = len(lines)
|
|
232
|
+
|
|
233
|
+
# Rewind to start and re-count lines to detect external appends
|
|
234
|
+
f.seek(0)
|
|
235
|
+
current_line_count = sum(1 for _ in f)
|
|
236
|
+
|
|
237
|
+
if current_line_count > original_line_count:
|
|
238
|
+
# External appends occurred! Re-read the appended lines
|
|
239
|
+
lines_appended = current_line_count - original_line_count
|
|
240
|
+
|
|
241
|
+
# Re-read from line position (start of appended lines)
|
|
242
|
+
f.seek(0)
|
|
243
|
+
all_current_lines = f.readlines()
|
|
244
|
+
|
|
245
|
+
# Extract only the new lines (the appended ones)
|
|
246
|
+
external_lines = all_current_lines[-lines_appended:]
|
|
247
|
+
if external_lines:
|
|
248
|
+
remaining_lines.extend(external_lines)
|
|
249
|
+
|
|
250
|
+
# Atomically truncate and write remaining lines back to original
|
|
251
|
+
# while still holding the lock
|
|
252
|
+
f.seek(0)
|
|
253
|
+
f.truncate()
|
|
254
|
+
f.writelines(remaining_lines)
|
|
255
|
+
|
|
256
|
+
finally:
|
|
257
|
+
# Release lock after all writes complete
|
|
258
|
+
release_lock(f.fileno())
|
|
120
259
|
|
|
121
|
-
keep_count = current_lines // 2 # Default: keep ~half
|
|
122
|
-
|
|
123
|
-
# If max_lines exceeded, keep lines just under threshold
|
|
124
|
-
if max_lines and current_lines > max_lines:
|
|
125
|
-
keep_count = min(keep_count, max_lines)
|
|
126
|
-
|
|
127
|
-
# If max_bytes exceeded, calculate how many lines fit under threshold
|
|
128
|
-
if max_bytes and current_bytes > max_bytes:
|
|
129
|
-
cumulative_bytes = 0
|
|
130
|
-
bytes_keep_count = 0
|
|
131
|
-
# Count from newest (end) backward
|
|
132
|
-
for i in range(len(lines) - 1, -1, -1):
|
|
133
|
-
line_bytes = len(lines[i].encode('utf-8'))
|
|
134
|
-
if cumulative_bytes + line_bytes <= max_bytes:
|
|
135
|
-
cumulative_bytes += line_bytes
|
|
136
|
-
bytes_keep_count += 1
|
|
137
|
-
else:
|
|
138
|
-
break
|
|
139
|
-
# Use the more conservative count
|
|
140
|
-
keep_count = min(keep_count, bytes_keep_count)
|
|
141
|
-
|
|
142
|
-
# Guard: ensure we keep at least 1 line, archive the rest
|
|
143
|
-
if keep_count <= 0:
|
|
144
|
-
# If calculated keep_count is 0 or negative, keep at least 1 line
|
|
145
|
-
keep_count = 1
|
|
146
|
-
|
|
147
|
-
# Split lines
|
|
148
|
-
archive_lines = lines[:-keep_count] if keep_count < len(lines) else []
|
|
149
|
-
remaining_lines = lines[-keep_count:] if keep_count > 0 else []
|
|
150
|
-
|
|
151
|
-
if not archive_lines:
|
|
152
|
-
# Nothing to archive
|
|
153
|
-
return 0
|
|
154
|
-
|
|
155
|
-
# Generate archive filename with UTC timestamp
|
|
156
|
-
utc_now = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
|
|
157
|
-
archive_filename = f"{logfile.stem}.{utc_now}.log"
|
|
158
|
-
archive_file = archive_path / archive_filename
|
|
159
|
-
|
|
160
|
-
# Write archive file
|
|
161
|
-
try:
|
|
162
|
-
write_lines(str(archive_file), archive_lines)
|
|
163
|
-
except IOError as e:
|
|
164
|
-
print(f"ERROR: {e}", file=sys.stderr)
|
|
165
|
-
return 1
|
|
166
|
-
|
|
167
|
-
# Write remaining lines back to original
|
|
168
|
-
try:
|
|
169
|
-
write_lines(str(logfile), remaining_lines)
|
|
170
260
|
except IOError as e:
|
|
171
261
|
print(f"ERROR: {e}", file=sys.stderr)
|
|
172
262
|
return 1
|