@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/ui/CLAUDE.md
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
- **render.py** — Renders `ui/web/dist/index.html` (wave-14 U9 cutover: no fallback), substituting the CSRF token via a unique sentinel (the sentinel persists through Vite's build). Requires `template_path` parameter; legacy fallback removed.
|
|
15
15
|
- **handler.py** — `DashboardHandler` (HTTP routing + all GET/POST endpoints incl. /api/tracker) and `run_server()`. Wave-14 additions: static serving (`GET /assets/*` from `ui/web/dist/assets/`), `/api/state` consolidated snapshot, `/api/session` for Vite dev server, `/api/cost` scorecard. Reads `config.X` / `csrf.SESSION_TOKEN` at call time.
|
|
16
16
|
- **cost.py** — Parser for `state/ledger/OUTCOMES-LEDGER.md` (markdown table); returns per-model and per-day aggregates, verdict scorecards, and optional dollar estimates (only if `aesop.config.json` supplies `pricing` map).
|
|
17
|
+
- **wave_prs.py** — Wave PR board collector: `get_wave_prs()` gathers open PRs (`gh pr list`) + PR-less `feat/*` branches (`git for-each-ref`), rolls check runs into passing/failing/pending/none, derives the top blocker, caches ~5s. Read-only; subprocess reads use `encoding='utf-8', errors='replace'`; degrades to `{available:false, error}` when gh is missing/un-authed. `AESOP_GH_BIN` overrides the gh binary.
|
|
17
18
|
|
|
18
19
|
## State Store Integration (Wave-15)
|
|
19
20
|
|
|
@@ -90,6 +91,7 @@ Configuration is resolved in this order (first match wins):
|
|
|
90
91
|
- `GET /api/state` — Consolidated first-paint snapshot: `{data, backlog, agents, tracker, status, cost}` in one round trip (reuses latest snapshots).
|
|
91
92
|
- `GET /api/session` — Returns `{token}` for Vite dev server; Origin-checked fail-closed (only local origins).
|
|
92
93
|
- `GET /api/cost` — Cost/scorecard summary from `state/ledger/OUTCOMES-LEDGER.md` (per-model, per-day, verdicts, optional pricing estimates).
|
|
94
|
+
- `GET /api/wave/prs` — Wave PR board: open PRs (`gh pr list`) + PR-less `feat/*` branches (`git for-each-ref`), each with CI rollup / mergeable / age / top blocker. Cached ~5s; degrades to `{available:false, error}` when gh is missing/un-authenticated (never a 500). `wave_prs.py` collector; polled by the frontend (not an SSE section — `gh` is too slow for the collector tick). Set `AESOP_GH_BIN` to override the gh binary path.
|
|
93
95
|
- `GET /events` — Server-Sent Events stream (read-only, no CSRF).
|
|
94
96
|
|
|
95
97
|
**Mutations (CSRF-gated)**:
|
package/ui/agents.py
CHANGED
|
@@ -9,6 +9,53 @@ from pathlib import Path
|
|
|
9
9
|
import config
|
|
10
10
|
|
|
11
11
|
|
|
12
|
+
def _path_is_contained(child, root):
|
|
13
|
+
"""Check if child path is contained within root path (no traversal).
|
|
14
|
+
|
|
15
|
+
Returns True if child is under root, False if it escapes (e.g., via ..).
|
|
16
|
+
Uses Path.is_relative_to (Python 3.9+) with a fallback for older runtimes.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
child: Path object (typically resolved)
|
|
20
|
+
root: Path object (typically resolved)
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
bool: True if child is contained within root, False otherwise
|
|
24
|
+
"""
|
|
25
|
+
try:
|
|
26
|
+
return child.is_relative_to(root.resolve())
|
|
27
|
+
except AttributeError:
|
|
28
|
+
# Path.is_relative_to requires Python 3.9+; fall back for older runtimes.
|
|
29
|
+
try:
|
|
30
|
+
child.relative_to(root.resolve())
|
|
31
|
+
return True
|
|
32
|
+
except ValueError:
|
|
33
|
+
return False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def sanitize_agents_for_broadcast(agents):
|
|
37
|
+
"""Strip large prompt fields from agents before SSE broadcast.
|
|
38
|
+
|
|
39
|
+
Wave-19 fix: Remove promptFull, dispatch_prompt, and other multi-KB fields
|
|
40
|
+
from agents snapshots before sending via SSE. The React app never reads
|
|
41
|
+
these full prompts (it lazy-fetches them via /agent?id= endpoint), so
|
|
42
|
+
stripping them saves bandwidth on every tick. Keeps summary fields only.
|
|
43
|
+
"""
|
|
44
|
+
sanitized = []
|
|
45
|
+
for agent in agents:
|
|
46
|
+
if not isinstance(agent, dict):
|
|
47
|
+
sanitized.append(agent)
|
|
48
|
+
continue
|
|
49
|
+
# Drop only the multi-KB prompt fields; keep everything else the
|
|
50
|
+
# frontend Agent contract reads (ui/web/src/lib/types.ts) — id,
|
|
51
|
+
# project, status, age_s, hint, startedAt, lastActivity,
|
|
52
|
+
# runtimeSeconds, tokensUsed, taskLabel.
|
|
53
|
+
strip_fields = {"promptFull", "dispatch_prompt"}
|
|
54
|
+
summary = {k: v for k, v in agent.items() if k not in strip_fields}
|
|
55
|
+
sanitized.append(summary)
|
|
56
|
+
return sanitized
|
|
57
|
+
|
|
58
|
+
|
|
12
59
|
def get_fleet_agents():
|
|
13
60
|
"""Detect running subagents by calling dash-extra.mjs --json.
|
|
14
61
|
|
|
@@ -30,6 +77,8 @@ def get_fleet_agents():
|
|
|
30
77
|
["node", str(dash_extra_path), "--json"],
|
|
31
78
|
capture_output=True,
|
|
32
79
|
text=True,
|
|
80
|
+
encoding='utf-8',
|
|
81
|
+
errors='replace',
|
|
33
82
|
timeout=5
|
|
34
83
|
)
|
|
35
84
|
if result.returncode == 0 and result.stdout:
|
|
@@ -53,6 +102,272 @@ def get_fleet_agents():
|
|
|
53
102
|
|
|
54
103
|
_AGENT_ID_FORBIDDEN = re.compile(r'\.\.|[/\\*?\[\]]')
|
|
55
104
|
|
|
105
|
+
# Transcript-tail bounds (defense against loading a whole multi-MB transcript
|
|
106
|
+
# into memory and against emitting an unbounded payload to the browser).
|
|
107
|
+
TRANSCRIPT_TAIL_LINES = 40 # last N NDJSON lines rendered in the drawer
|
|
108
|
+
TRANSCRIPT_TAIL_MAX_BYTES = 256 * 1024 # only ever seek-read this much from EOF
|
|
109
|
+
TRANSCRIPT_TAIL_ENTRY_MAXLEN = 2000 # per-entry text cap (chars)
|
|
110
|
+
|
|
111
|
+
# Best-effort credential redaction for the transcript tail. These are the
|
|
112
|
+
# high-confidence, unambiguous secret FORMATS (mirroring the fatal rules in
|
|
113
|
+
# tools/secret_scan.py) plus bearer/authorization values. This is defense in
|
|
114
|
+
# depth, NOT a guarantee: a novel or bespoke credential format can still slip
|
|
115
|
+
# through, so the tail is a read-only convenience, never an audited-clean feed.
|
|
116
|
+
# tools/secret_scan.py is deliberately NOT imported (it is not on the UI import
|
|
117
|
+
# path and pulling it in would couple the dashboard to the push-gate tool); the
|
|
118
|
+
# patterns are duplicated here in a tiny, stdlib-only form.
|
|
119
|
+
_REDACTION_PATTERNS = [
|
|
120
|
+
re.compile(r'-----BEGIN[^\n]*PRIVATE KEY-----', re.IGNORECASE),
|
|
121
|
+
re.compile(r'AKIA[0-9A-Z]{16}'),
|
|
122
|
+
re.compile(r'(?:ghp_|gho_|ghu_|ghs_|ghr_|github_pat_)[A-Za-z0-9_]{20,}'),
|
|
123
|
+
re.compile(r'xox[baprs]-[A-Za-z0-9-]{10,}'),
|
|
124
|
+
re.compile(r'sk-[A-Za-z0-9_\-]{20,}'),
|
|
125
|
+
re.compile(r'(?i:bearer)\s+[A-Za-z0-9._\-]{20,}'),
|
|
126
|
+
# key/secret/password/token = <value> (quoted or bare) — mask the value.
|
|
127
|
+
re.compile(
|
|
128
|
+
r'(?i)((?:api[_-]?key|secret|password|passwd|token|authorization)'
|
|
129
|
+
r'\s*[:=]\s*)([^\s"\',}]{12,})'
|
|
130
|
+
),
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _redact_secrets(text):
|
|
135
|
+
"""Best-effort masking of high-confidence credential formats in free text.
|
|
136
|
+
|
|
137
|
+
Returns text with any matched secret replaced by a short prefix + a
|
|
138
|
+
REDACTED marker (so an operator can still eyeball that *something* was
|
|
139
|
+
there). Never raises. See _REDACTION_PATTERNS for the (intentionally
|
|
140
|
+
limited) coverage and its documented limits.
|
|
141
|
+
"""
|
|
142
|
+
if not text:
|
|
143
|
+
return text
|
|
144
|
+
|
|
145
|
+
def _mask(match):
|
|
146
|
+
groups = match.groups()
|
|
147
|
+
if len(groups) == 2:
|
|
148
|
+
# key/value form: keep the "key = " prefix, mask only the value.
|
|
149
|
+
return f"{groups[0]}***REDACTED***"
|
|
150
|
+
token = match.group(0)
|
|
151
|
+
prefix = token[:4] if len(token) > 8 else ""
|
|
152
|
+
return f"{prefix}***REDACTED***"
|
|
153
|
+
|
|
154
|
+
for pat in _REDACTION_PATTERNS:
|
|
155
|
+
try:
|
|
156
|
+
text = pat.sub(_mask, text)
|
|
157
|
+
except re.error:
|
|
158
|
+
continue
|
|
159
|
+
return text
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _resolve_transcript_path(agent_id):
|
|
163
|
+
"""Resolve an agent id to its on-disk transcript file, path-traversal-safe.
|
|
164
|
+
|
|
165
|
+
Shared by extract_agent_dispatch_prompt() and get_agent_detail() so the
|
|
166
|
+
security contract (reject `.. / \\ * ? [ ]` before any glob, then verify the
|
|
167
|
+
resolved match stays inside config.TRANSCRIPTS_ROOT) can never drift between
|
|
168
|
+
the two endpoints.
|
|
169
|
+
|
|
170
|
+
Returns (Path, None) on a clean match, or (None, error_dict) otherwise.
|
|
171
|
+
error_dict carries "invalid": True for a rejected input (caller maps to
|
|
172
|
+
HTTP 400); its absence means a well-formed id that simply had no transcript
|
|
173
|
+
(caller maps to HTTP 404). Never raises for the expected failure modes.
|
|
174
|
+
"""
|
|
175
|
+
if not agent_id or _AGENT_ID_FORBIDDEN.search(agent_id):
|
|
176
|
+
return None, {"error": "invalid agent id", "invalid": True}
|
|
177
|
+
|
|
178
|
+
if not config.TRANSCRIPTS_ROOT.exists():
|
|
179
|
+
return None, {"error": f"transcripts root not found at {config.TRANSCRIPTS_ROOT}"}
|
|
180
|
+
|
|
181
|
+
matches = sorted(
|
|
182
|
+
config.TRANSCRIPTS_ROOT.glob(f"**/agent-{agent_id}*.jsonl"),
|
|
183
|
+
key=lambda p: p.stat().st_mtime, reverse=True,
|
|
184
|
+
)
|
|
185
|
+
if not matches:
|
|
186
|
+
return None, {"error": f"transcript not found for {agent_id}"}
|
|
187
|
+
|
|
188
|
+
output_file = matches[0]
|
|
189
|
+
if not _path_is_contained(output_file.resolve(), config.TRANSCRIPTS_ROOT):
|
|
190
|
+
return None, {"error": "resolved path outside transcripts root", "invalid": True}
|
|
191
|
+
|
|
192
|
+
return output_file, None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _read_tail_lines(path, max_lines=TRANSCRIPT_TAIL_LINES,
|
|
196
|
+
max_bytes=TRANSCRIPT_TAIL_MAX_BYTES):
|
|
197
|
+
"""Return roughly the last `max_lines` text lines of a file, bounded.
|
|
198
|
+
|
|
199
|
+
Seeks to at most `max_bytes` from EOF so a huge transcript is never fully
|
|
200
|
+
read into memory. Decodes utf-8 with errors='replace' (wave-27 lesson) so
|
|
201
|
+
undecodable bytes never raise. When the read started mid-file the first
|
|
202
|
+
(probably partial) line is dropped. Returns (lines, truncated) where
|
|
203
|
+
truncated is True if the file was larger than the window we read.
|
|
204
|
+
"""
|
|
205
|
+
try:
|
|
206
|
+
size = path.stat().st_size
|
|
207
|
+
except OSError:
|
|
208
|
+
return [], False
|
|
209
|
+
to_read = min(size, max_bytes)
|
|
210
|
+
try:
|
|
211
|
+
with open(path, 'rb') as f:
|
|
212
|
+
if size > to_read:
|
|
213
|
+
f.seek(size - to_read)
|
|
214
|
+
raw = f.read()
|
|
215
|
+
except OSError:
|
|
216
|
+
return [], False
|
|
217
|
+
text = raw.decode('utf-8', errors='replace')
|
|
218
|
+
lines = text.splitlines()
|
|
219
|
+
windowed = size > to_read
|
|
220
|
+
if windowed and len(lines) > 1:
|
|
221
|
+
lines = lines[1:] # drop the partial leading line
|
|
222
|
+
truncated = windowed or len(lines) > max_lines
|
|
223
|
+
return lines[-max_lines:], truncated
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _extract_line_text(obj):
|
|
227
|
+
"""Pull a readable text summary out of one parsed transcript record.
|
|
228
|
+
|
|
229
|
+
Handles the shapes Claude Code emits: message.content as a plain string, or
|
|
230
|
+
as a list of content blocks (text / tool_use / tool_result / thinking).
|
|
231
|
+
Returns a plain string (never HTML); the frontend renders it as text.
|
|
232
|
+
"""
|
|
233
|
+
msg = obj.get("message")
|
|
234
|
+
content = msg.get("content") if isinstance(msg, dict) else None
|
|
235
|
+
if content is None:
|
|
236
|
+
content = obj.get("content")
|
|
237
|
+
|
|
238
|
+
if isinstance(content, str):
|
|
239
|
+
return content
|
|
240
|
+
if isinstance(content, list):
|
|
241
|
+
parts = []
|
|
242
|
+
for block in content:
|
|
243
|
+
if isinstance(block, str):
|
|
244
|
+
parts.append(block)
|
|
245
|
+
elif isinstance(block, dict):
|
|
246
|
+
btype = block.get("type")
|
|
247
|
+
if btype == "text" and isinstance(block.get("text"), str):
|
|
248
|
+
parts.append(block["text"])
|
|
249
|
+
elif btype == "thinking" and isinstance(block.get("thinking"), str):
|
|
250
|
+
parts.append(f"[thinking] {block['thinking']}")
|
|
251
|
+
elif btype == "tool_use":
|
|
252
|
+
parts.append(f"[tool_use: {block.get('name', '?')}]")
|
|
253
|
+
elif btype == "tool_result":
|
|
254
|
+
parts.append("[tool_result]")
|
|
255
|
+
return "\n".join(p for p in parts if p)
|
|
256
|
+
return ""
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _summarize_transcript_line(raw_line):
|
|
260
|
+
"""Turn one NDJSON line into a {"type", "text"} tail entry, or None to skip.
|
|
261
|
+
|
|
262
|
+
Robust to non-JSON / non-dict lines (returned as a "raw" entry). Text is
|
|
263
|
+
secret-redacted and length-capped so the payload stays bounded and XSS-safe
|
|
264
|
+
(plain strings only — the client escapes on render).
|
|
265
|
+
"""
|
|
266
|
+
raw_line = raw_line.strip()
|
|
267
|
+
if not raw_line:
|
|
268
|
+
return None
|
|
269
|
+
try:
|
|
270
|
+
obj = json.loads(raw_line)
|
|
271
|
+
except (json.JSONDecodeError, ValueError):
|
|
272
|
+
return {"type": "raw", "text": _redact_secrets(raw_line[:TRANSCRIPT_TAIL_ENTRY_MAXLEN])}
|
|
273
|
+
if not isinstance(obj, dict):
|
|
274
|
+
return {"type": "raw", "text": _redact_secrets(str(obj)[:TRANSCRIPT_TAIL_ENTRY_MAXLEN])}
|
|
275
|
+
entry_type = str(obj.get("type", "unknown"))
|
|
276
|
+
text = _extract_line_text(obj)
|
|
277
|
+
text = _redact_secrets(text[:TRANSCRIPT_TAIL_ENTRY_MAXLEN])
|
|
278
|
+
return {"type": entry_type, "text": text}
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def get_agent_detail(agent_id, tail_lines=TRANSCRIPT_TAIL_LINES):
|
|
282
|
+
"""Agent detail for the inspector drawer: dispatch prompt + bounded tail.
|
|
283
|
+
|
|
284
|
+
Read-only. Reuses _resolve_transcript_path() for the same path-traversal /
|
|
285
|
+
glob-injection protection as extract_agent_dispatch_prompt(), then reads
|
|
286
|
+
ONLY the last ~tail_lines lines of the transcript (never the whole file) and
|
|
287
|
+
returns them as a list of {type, text} entries with best-effort credential
|
|
288
|
+
redaction applied.
|
|
289
|
+
|
|
290
|
+
Success shape:
|
|
291
|
+
{id, dispatch_prompt, dispatcher, model, message_count, first_seen,
|
|
292
|
+
last_activity, transcript_tail: [{type, text}, ...], tail_truncated}
|
|
293
|
+
|
|
294
|
+
Error shape (same convention as extract_agent_dispatch_prompt):
|
|
295
|
+
{"error": str} -> caller returns 404
|
|
296
|
+
{"error": str, "invalid": True} -> caller returns 400
|
|
297
|
+
Never raises: an unexpected failure degrades to a generic error dict.
|
|
298
|
+
"""
|
|
299
|
+
try:
|
|
300
|
+
output_file, err = _resolve_transcript_path(agent_id)
|
|
301
|
+
if err is not None:
|
|
302
|
+
return err
|
|
303
|
+
|
|
304
|
+
stat = output_file.stat()
|
|
305
|
+
first_seen = int(stat.st_mtime)
|
|
306
|
+
last_activity = int(stat.st_mtime)
|
|
307
|
+
|
|
308
|
+
dispatch_prompt = None
|
|
309
|
+
model = None
|
|
310
|
+
parent_uuid = None
|
|
311
|
+
message_count = 0
|
|
312
|
+
|
|
313
|
+
# Header parse: first line = dispatch prompt; scan a few for the model.
|
|
314
|
+
# Bounded read of just the head lines (the tail is read separately).
|
|
315
|
+
try:
|
|
316
|
+
with open(output_file, 'r', encoding='utf-8', errors='replace') as f:
|
|
317
|
+
head = []
|
|
318
|
+
for i, line in enumerate(f):
|
|
319
|
+
if i < 20:
|
|
320
|
+
head.append(line)
|
|
321
|
+
message_count += 1
|
|
322
|
+
if head:
|
|
323
|
+
try:
|
|
324
|
+
first = json.loads(head[0])
|
|
325
|
+
if first.get('type') == 'user':
|
|
326
|
+
msg = first.get('message', {})
|
|
327
|
+
dispatch_prompt = msg.get('content', '')
|
|
328
|
+
parent_uuid = first.get('parentUuid')
|
|
329
|
+
except (json.JSONDecodeError, KeyError):
|
|
330
|
+
pass
|
|
331
|
+
for line in head[1:]:
|
|
332
|
+
try:
|
|
333
|
+
obj = json.loads(line)
|
|
334
|
+
if obj.get('type') == 'assistant' and not model and 'model' in obj:
|
|
335
|
+
model = obj.get('model')
|
|
336
|
+
except (json.JSONDecodeError, KeyError):
|
|
337
|
+
pass
|
|
338
|
+
except OSError:
|
|
339
|
+
pass
|
|
340
|
+
|
|
341
|
+
# dispatch_prompt may be a content-block list; normalise to a string.
|
|
342
|
+
if isinstance(dispatch_prompt, list):
|
|
343
|
+
dispatch_prompt = _extract_line_text({"content": dispatch_prompt})
|
|
344
|
+
dispatch_prompt = _redact_secrets(dispatch_prompt or "")
|
|
345
|
+
|
|
346
|
+
tail_raw, truncated = _read_tail_lines(output_file, max_lines=tail_lines)
|
|
347
|
+
transcript_tail = []
|
|
348
|
+
for line in tail_raw:
|
|
349
|
+
entry = _summarize_transcript_line(line)
|
|
350
|
+
if entry is not None:
|
|
351
|
+
transcript_tail.append(entry)
|
|
352
|
+
|
|
353
|
+
dispatcher = "main thread" if parent_uuid is None else "parent agent"
|
|
354
|
+
|
|
355
|
+
return {
|
|
356
|
+
"id": agent_id,
|
|
357
|
+
"dispatch_prompt": dispatch_prompt,
|
|
358
|
+
"dispatcher": dispatcher,
|
|
359
|
+
"model": model or "unknown",
|
|
360
|
+
"message_count": message_count,
|
|
361
|
+
"first_seen": first_seen,
|
|
362
|
+
"last_activity": last_activity,
|
|
363
|
+
"transcript_tail": transcript_tail,
|
|
364
|
+
"tail_truncated": truncated,
|
|
365
|
+
}
|
|
366
|
+
except Exception as e:
|
|
367
|
+
print(f"[get_agent_detail] Uncaught exception: {e}", file=sys.stderr)
|
|
368
|
+
return {"error": "Failed to load agent detail"}
|
|
369
|
+
|
|
370
|
+
|
|
56
371
|
def extract_agent_dispatch_prompt(agent_id):
|
|
57
372
|
"""
|
|
58
373
|
Extract dispatch prompt and metadata from agent jsonl transcript.
|
|
@@ -70,37 +385,12 @@ def extract_agent_dispatch_prompt(agent_id):
|
|
|
70
385
|
the same file format that the dashboard actually finds.
|
|
71
386
|
"""
|
|
72
387
|
try:
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
if not config.TRANSCRIPTS_ROOT.exists():
|
|
80
|
-
return {"error": f"transcripts root not found at {config.TRANSCRIPTS_ROOT}"}
|
|
81
|
-
|
|
82
|
-
# Glob for matching files (prefix-match handles truncated IDs)
|
|
83
|
-
matches = sorted(
|
|
84
|
-
config.TRANSCRIPTS_ROOT.glob(f"**/agent-{agent_id}*.jsonl"),
|
|
85
|
-
key=lambda p: p.stat().st_mtime, reverse=True,
|
|
86
|
-
)
|
|
87
|
-
if not matches:
|
|
88
|
-
return {"error": f"transcript not found for {agent_id}"}
|
|
89
|
-
output_file = matches[0]
|
|
90
|
-
|
|
91
|
-
# Containment check: the resolved match must stay inside config.TRANSCRIPTS_ROOT.
|
|
92
|
-
# Belt-and-suspenders alongside the input rejection above.
|
|
93
|
-
try:
|
|
94
|
-
is_contained = output_file.resolve().is_relative_to(config.TRANSCRIPTS_ROOT.resolve())
|
|
95
|
-
except AttributeError:
|
|
96
|
-
# Path.is_relative_to requires Python 3.9+; fall back for older runtimes.
|
|
97
|
-
try:
|
|
98
|
-
output_file.resolve().relative_to(config.TRANSCRIPTS_ROOT.resolve())
|
|
99
|
-
is_contained = True
|
|
100
|
-
except ValueError:
|
|
101
|
-
is_contained = False
|
|
102
|
-
if not is_contained:
|
|
103
|
-
return {"error": "resolved path outside transcripts root", "invalid": True}
|
|
388
|
+
# Path-traversal / glob-injection protection + prefix-match resolution
|
|
389
|
+
# live in the shared _resolve_transcript_path() so this endpoint and the
|
|
390
|
+
# inspector's get_agent_detail() can never drift apart on security.
|
|
391
|
+
output_file, err = _resolve_transcript_path(agent_id)
|
|
392
|
+
if err is not None:
|
|
393
|
+
return err
|
|
104
394
|
|
|
105
395
|
dispatch_prompt = None
|
|
106
396
|
message_count = 0
|
|
@@ -110,7 +400,7 @@ def extract_agent_dispatch_prompt(agent_id):
|
|
|
110
400
|
last_activity = None
|
|
111
401
|
|
|
112
402
|
# Parse NDJSON (one JSON per line)
|
|
113
|
-
with open(output_file, 'r', encoding='utf-8', errors='
|
|
403
|
+
with open(output_file, 'r', encoding='utf-8', errors='replace') as f:
|
|
114
404
|
lines = f.readlines()
|
|
115
405
|
message_count = len(lines)
|
|
116
406
|
|
|
@@ -143,6 +433,14 @@ def extract_agent_dispatch_prompt(agent_id):
|
|
|
143
433
|
if not dispatch_prompt:
|
|
144
434
|
return {"error": f"no dispatch prompt found"}
|
|
145
435
|
|
|
436
|
+
# dispatch_prompt may be a content-block list; normalise to a string,
|
|
437
|
+
# then redact credentials before this leaves the process (same
|
|
438
|
+
# contract as get_agent_detail() — wave-32 fix, this endpoint had
|
|
439
|
+
# drifted and was returning secrets unredacted).
|
|
440
|
+
if isinstance(dispatch_prompt, list):
|
|
441
|
+
dispatch_prompt = _extract_line_text({"content": dispatch_prompt})
|
|
442
|
+
dispatch_prompt = _redact_secrets(dispatch_prompt or "")
|
|
443
|
+
|
|
146
444
|
# Infer dispatcher: if parentUuid is null, it's main thread; otherwise parent agent
|
|
147
445
|
dispatcher = "main thread" if parent_uuid is None else "parent agent"
|
|
148
446
|
|
|
@@ -156,7 +454,8 @@ def extract_agent_dispatch_prompt(agent_id):
|
|
|
156
454
|
"last_activity": last_activity,
|
|
157
455
|
}
|
|
158
456
|
except Exception as e:
|
|
159
|
-
|
|
457
|
+
print(f"[extract_agent_dispatch_prompt] Uncaught exception: {e}", file=sys.stderr)
|
|
458
|
+
return {"error": "Failed to extract dispatch prompt"}
|
|
160
459
|
|
|
161
460
|
def _transcripts_fingerprint():
|
|
162
461
|
"""Cheap fs-stat-only fingerprint of the transcripts tree.
|
package/ui/api/tracker.py
CHANGED
|
@@ -33,7 +33,9 @@ def list_items(status=None, priority=None):
|
|
|
33
33
|
items = get_tracker_items(status=status, priority=priority)
|
|
34
34
|
return 200, items
|
|
35
35
|
except Exception as e:
|
|
36
|
-
|
|
36
|
+
import sys
|
|
37
|
+
print(f"[tracker.list_items] Uncaught exception: {e}", file=sys.stderr)
|
|
38
|
+
return 500, {"error": "Internal server error"}
|
|
37
39
|
|
|
38
40
|
|
|
39
41
|
def create(headers, body_bytes):
|
|
@@ -59,7 +61,9 @@ def create(headers, body_bytes):
|
|
|
59
61
|
except json.JSONDecodeError:
|
|
60
62
|
return 400, {"error": "Invalid JSON"}
|
|
61
63
|
except Exception as e:
|
|
62
|
-
|
|
64
|
+
import sys
|
|
65
|
+
print(f"[tracker.create] Uncaught exception: {e}", file=sys.stderr)
|
|
66
|
+
return 500, {"error": "Internal server error"}
|
|
63
67
|
|
|
64
68
|
|
|
65
69
|
def update(item_id, body_bytes):
|
|
@@ -83,14 +87,16 @@ def update(item_id, body_bytes):
|
|
|
83
87
|
preserves the pre-split handler's behavior).
|
|
84
88
|
"""
|
|
85
89
|
try:
|
|
86
|
-
body_str = (body_bytes or b'').decode('utf-8', errors='
|
|
90
|
+
body_str = (body_bytes or b'').decode('utf-8', errors='replace')
|
|
87
91
|
update_data = json.loads(body_str)
|
|
88
92
|
item = update_tracker_item(item_id, update_data)
|
|
89
93
|
return 200, item
|
|
90
94
|
except Exception as e:
|
|
95
|
+
import sys
|
|
91
96
|
if "404" in str(e):
|
|
92
|
-
return 404, {"error":
|
|
93
|
-
|
|
97
|
+
return 404, {"error": "404 Item not found"}
|
|
98
|
+
print(f"[tracker.update] Uncaught exception: {e}", file=sys.stderr)
|
|
99
|
+
return 500, {"error": "Internal server error"}
|
|
94
100
|
|
|
95
101
|
|
|
96
102
|
def delete(item_id):
|
|
@@ -111,6 +117,8 @@ def delete(item_id):
|
|
|
111
117
|
item = delete_tracker_item(item_id)
|
|
112
118
|
return 200, item
|
|
113
119
|
except Exception as e:
|
|
120
|
+
import sys
|
|
114
121
|
if "404" in str(e):
|
|
115
|
-
return 404, {"error":
|
|
116
|
-
|
|
122
|
+
return 404, {"error": "404 Item not found"}
|
|
123
|
+
print(f"[tracker.delete] Uncaught exception: {e}", file=sys.stderr)
|
|
124
|
+
return 500, {"error": "Internal server error"}
|
package/ui/collectors.py
CHANGED
|
@@ -233,7 +233,7 @@ def get_main_thread_messages():
|
|
|
233
233
|
return messages
|
|
234
234
|
|
|
235
235
|
newest = jsonl_files[0]
|
|
236
|
-
with open(newest, 'r', encoding='utf-8', errors='
|
|
236
|
+
with open(newest, 'r', encoding='utf-8', errors='replace') as f:
|
|
237
237
|
lines = f.readlines()
|
|
238
238
|
# Get last 30 lines to extract ~12 message turns
|
|
239
239
|
for line in lines[-30:]:
|
|
@@ -639,6 +639,85 @@ def _snapshot_orchestrator_status():
|
|
|
639
639
|
print(f"[status] Snapshot error: {e}", file=sys.stderr)
|
|
640
640
|
return {"orchestrators": []}
|
|
641
641
|
|
|
642
|
+
def _recover_stranded_inbox_files():
|
|
643
|
+
"""Defect 3 fix: Recovery sweep for .tracker-inbox.jsonl.processing-* files.
|
|
644
|
+
|
|
645
|
+
If drain_tracker_inbox crashes mid-process, it leaves a .processing-* file.
|
|
646
|
+
This sweep re-ingests those files on the next drain call, preventing silent
|
|
647
|
+
data loss. Returns list of items recovered.
|
|
648
|
+
"""
|
|
649
|
+
recovered = []
|
|
650
|
+
try:
|
|
651
|
+
for processing_file in sorted(config.STATE_DIR.glob(".tracker-inbox.jsonl.processing-*")):
|
|
652
|
+
if not processing_file.exists():
|
|
653
|
+
continue
|
|
654
|
+
try:
|
|
655
|
+
content = processing_file.read_text(encoding='utf-8')
|
|
656
|
+
if not content.strip():
|
|
657
|
+
try:
|
|
658
|
+
processing_file.unlink()
|
|
659
|
+
except Exception:
|
|
660
|
+
pass
|
|
661
|
+
continue
|
|
662
|
+
|
|
663
|
+
# Build dedup hash set from both tracker.json and event store
|
|
664
|
+
existing_hashes = set()
|
|
665
|
+
|
|
666
|
+
# Check rendered tracker.json
|
|
667
|
+
tracker_json = load_tracker()
|
|
668
|
+
for item in tracker_json.get("items", []):
|
|
669
|
+
source = item.get("source", "")
|
|
670
|
+
title = item.get("title", "")
|
|
671
|
+
h = hashlib.sha256((source + ":" + title).encode()).hexdigest()
|
|
672
|
+
existing_hashes.add(h)
|
|
673
|
+
|
|
674
|
+
# Check event store projection
|
|
675
|
+
try:
|
|
676
|
+
api = _tracker_api()
|
|
677
|
+
projected = api.project("tracker")
|
|
678
|
+
for item in projected.get("items", []):
|
|
679
|
+
source = item.get("source", "")
|
|
680
|
+
title = item.get("title", "")
|
|
681
|
+
h = hashlib.sha256((source + ":" + title).encode()).hexdigest()
|
|
682
|
+
existing_hashes.add(h)
|
|
683
|
+
except Exception as e:
|
|
684
|
+
print(f"[inbox] Recovery: failed to project tracker state: {e}", file=sys.stderr)
|
|
685
|
+
|
|
686
|
+
# Reprocess lines from the recovered file
|
|
687
|
+
lines = content.strip().splitlines()
|
|
688
|
+
for line in lines:
|
|
689
|
+
line = line.strip()
|
|
690
|
+
if not line:
|
|
691
|
+
continue
|
|
692
|
+
try:
|
|
693
|
+
entry = json.loads(line)
|
|
694
|
+
if not isinstance(entry, dict):
|
|
695
|
+
continue
|
|
696
|
+
source = entry.get("source", "")
|
|
697
|
+
title = entry.get("title", "")
|
|
698
|
+
h = hashlib.sha256((source + ":" + title).encode()).hexdigest()
|
|
699
|
+
|
|
700
|
+
if h not in existing_hashes:
|
|
701
|
+
item = create_tracker_item(entry)
|
|
702
|
+
recovered.append(item)
|
|
703
|
+
existing_hashes.add(h)
|
|
704
|
+
except (json.JSONDecodeError, Exception):
|
|
705
|
+
pass
|
|
706
|
+
|
|
707
|
+
# Only delete after successful re-ingest
|
|
708
|
+
try:
|
|
709
|
+
processing_file.unlink()
|
|
710
|
+
except Exception as e:
|
|
711
|
+
print(f"[inbox] Recovery: failed to unlink {processing_file}: {e}", file=sys.stderr)
|
|
712
|
+
|
|
713
|
+
except Exception as e:
|
|
714
|
+
print(f"[inbox] Recovery sweep error on {processing_file}: {e}", file=sys.stderr)
|
|
715
|
+
except Exception as e:
|
|
716
|
+
print(f"[inbox] Recovery sweep failed: {e}", file=sys.stderr)
|
|
717
|
+
|
|
718
|
+
return recovered
|
|
719
|
+
|
|
720
|
+
|
|
642
721
|
def drain_tracker_inbox():
|
|
643
722
|
"""Drain .tracker-inbox.jsonl, create items idempotently.
|
|
644
723
|
|
|
@@ -646,31 +725,40 @@ def drain_tracker_inbox():
|
|
|
646
725
|
to ensure only one caller processes it under concurrent access. Strengthen
|
|
647
726
|
dedup to check both tracker.json AND api.project("tracker") so items
|
|
648
727
|
in the event store but not yet rendered are also excluded.
|
|
728
|
+
|
|
729
|
+
Defect 3 fix: Before processing the current inbox, perform a recovery sweep
|
|
730
|
+
to re-ingest leftover .tracker-inbox.jsonl.processing-* files from any
|
|
731
|
+
previous crashes. This prevents silent data loss if a crash happens
|
|
732
|
+
mid-drain: stranded files are recovered on the next drain call.
|
|
649
733
|
"""
|
|
650
734
|
inbox_file = config.STATE_DIR / ".tracker-inbox.jsonl"
|
|
735
|
+
|
|
736
|
+
# Defect 3: Recovery sweep for stranded .processing-* files from prior crashes.
|
|
737
|
+
created = _recover_stranded_inbox_files()
|
|
738
|
+
|
|
739
|
+
# Now process the current inbox if it exists
|
|
651
740
|
if not inbox_file.exists():
|
|
652
|
-
return
|
|
741
|
+
return created
|
|
653
742
|
|
|
654
|
-
|
|
743
|
+
# Defect 1: Atomically rename inbox to unique processing name first.
|
|
744
|
+
# This ensures only one caller wins; others see no file.
|
|
745
|
+
processing_file = inbox_file.with_name(
|
|
746
|
+
f".tracker-inbox.jsonl.processing-{secrets.token_hex(8)}"
|
|
747
|
+
)
|
|
655
748
|
try:
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
except FileNotFoundError:
|
|
664
|
-
# Another caller already renamed it; nothing to process
|
|
665
|
-
return []
|
|
666
|
-
except Exception as e:
|
|
667
|
-
print(f"[inbox] Failed to rename inbox: {e}", file=sys.stderr)
|
|
668
|
-
return []
|
|
749
|
+
os.replace(str(inbox_file), str(processing_file))
|
|
750
|
+
except FileNotFoundError:
|
|
751
|
+
# Another caller already renamed it; nothing to process
|
|
752
|
+
return created
|
|
753
|
+
except Exception as e:
|
|
754
|
+
print(f"[inbox] Failed to rename inbox: {e}", file=sys.stderr)
|
|
755
|
+
return created
|
|
669
756
|
|
|
757
|
+
try:
|
|
670
758
|
content = processing_file.read_text(encoding='utf-8')
|
|
671
759
|
if not content.strip():
|
|
672
760
|
processing_file.unlink()
|
|
673
|
-
return
|
|
761
|
+
return created
|
|
674
762
|
|
|
675
763
|
lines = content.strip().splitlines()
|
|
676
764
|
|
|
@@ -707,29 +795,34 @@ def drain_tracker_inbox():
|
|
|
707
795
|
continue
|
|
708
796
|
try:
|
|
709
797
|
entry = json.loads(line)
|
|
710
|
-
if not isinstance(entry, dict):
|
|
711
|
-
rejects.append(line)
|
|
712
|
-
continue
|
|
713
|
-
|
|
714
|
-
source = entry.get("source", "")
|
|
715
|
-
title = entry.get("title", "")
|
|
716
|
-
h = hashlib.sha256((source + ":" + title).encode()).hexdigest()
|
|
717
|
-
|
|
718
|
-
if h not in existing_hashes:
|
|
719
|
-
item = create_tracker_item(entry)
|
|
720
|
-
created.append(item)
|
|
721
|
-
existing_hashes.add(h)
|
|
722
798
|
except json.JSONDecodeError:
|
|
723
799
|
rejects.append(line)
|
|
724
|
-
|
|
725
|
-
|
|
800
|
+
continue
|
|
801
|
+
|
|
802
|
+
if not isinstance(entry, dict):
|
|
803
|
+
rejects.append(line)
|
|
804
|
+
continue
|
|
805
|
+
|
|
806
|
+
source = entry.get("source", "")
|
|
807
|
+
title = entry.get("title", "")
|
|
808
|
+
h = hashlib.sha256((source + ":" + title).encode()).hexdigest()
|
|
809
|
+
|
|
810
|
+
if h not in existing_hashes:
|
|
811
|
+
# create_tracker_item can raise real errors (not malformed JSON)
|
|
812
|
+
# let those bubble up rather than silently adding to rejects
|
|
813
|
+
item = create_tracker_item(entry)
|
|
814
|
+
created.append(item)
|
|
815
|
+
existing_hashes.add(h)
|
|
726
816
|
|
|
727
817
|
if rejects:
|
|
728
818
|
rejects_file = inbox_file.with_name(".tracker-inbox.rejects")
|
|
729
819
|
rejects_file.write_text("\n".join(rejects) + "\n", encoding='utf-8')
|
|
730
820
|
|
|
821
|
+
# Only delete the processing file after successful completion.
|
|
822
|
+
# If an exception occurs, the file is left behind for recovery.
|
|
731
823
|
processing_file.unlink()
|
|
732
824
|
except Exception as e:
|
|
733
|
-
print(f"[inbox] Drain error: {e}", file=sys.stderr)
|
|
825
|
+
print(f"[inbox] Drain error: {e}; processing file {processing_file.name} left for recovery", file=sys.stderr)
|
|
826
|
+
# Don't delete the file on error; let recovery sweep handle it next time
|
|
734
827
|
|
|
735
828
|
return created
|