@m13v/s4l 1.6.203 → 1.6.204-rc.1
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/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_menubar.py +64 -0
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/reap_stale_claude_sessions.py +134 -0
- package/scripts/release-mcpb.sh +22 -5
- package/scripts/send_diagnostic_report.py +74 -0
package/mcp/dist/version.json
CHANGED
package/mcp/manifest.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"dxt_version": "0.1",
|
|
3
3
|
"name": "social-autoposter",
|
|
4
4
|
"display_name": "S4L",
|
|
5
|
-
"version": "1.6.
|
|
5
|
+
"version": "1.6.204-rc.1",
|
|
6
6
|
"description": "Draft, review, approve, and autopilot X/Twitter posts.",
|
|
7
7
|
"long_description": "## **⚠️ The disclaimer above is generic Claude boilerplate.** Anthropic shows the same warning on every plugin regardless of what it does; any plugin has the same level of access as any app you download from the internet.\n\nS4L is an open source product developed by Mediar.ai Incorporated, a VC-backed San Francisco-based startup.\n\nTo get started:\n\n1\\. Copy this prompt: **Set me up on S4L plugin end to end**\n\n2\\. Quit with CMD+Q, reopen Claude, paste into a new chat.\n\nWhat happens next:\n\n* About every 5 minutes S4L scans X for posts that match your topics and drafts replies in your voice.\n* Drafts show up as review cards, usually the first within a few minutes. Nothing is posted automatically; you approve each one.\n* Posting autopilot stays off until you explicitly turn it on.",
|
|
8
8
|
"author": {
|
|
@@ -227,6 +227,43 @@ REARM_PROMPT = (
|
|
|
227
227
|
"Do not redo my X connection or project setup — only register the scheduled task. "
|
|
228
228
|
"Keep replies short."
|
|
229
229
|
)
|
|
230
|
+
# Universal diagnose-and-heal prompt behind the ⚠ "Diagnose & fix" menu item. One
|
|
231
|
+
# prompt for EVERY persistent attention state (draft stuck, rate-limited, schedule
|
|
232
|
+
# missing/disabled): the menu bar can only see the symptom, Claude on the box can
|
|
233
|
+
# see the cause. The prompt encodes the known heal patterns (the 2026-07-06
|
|
234
|
+
# dead-claim incident chief among them), forbids code hot patches, and ends by
|
|
235
|
+
# shipping a report back to us via scripts/send_diagnostic_report.py, so every
|
|
236
|
+
# click doubles as fleet telemetry about what actually breaks in the field.
|
|
237
|
+
DIAGNOSE_PROMPT_TEMPLATE = (
|
|
238
|
+
"The S4L plugin's menu bar on this machine is showing a persistent warning. "
|
|
239
|
+
"Reason code: {reason}. Detail: {detail}. Diagnose and heal it now.\n"
|
|
240
|
+
"Evidence lives under ~/.social-autoposter-mcp: activity.json (the producer's "
|
|
241
|
+
"live drafting label), claude-queue/ (pending/, running/ — each running job "
|
|
242
|
+
"stamps claim_pid, result/, provider.log, reaper-status.json, drain-status.json) "
|
|
243
|
+
"and the reaper log at repo/package/skill/logs/launchd-claude-reaper-stderr.log. "
|
|
244
|
+
"Worker transcripts: the *.jsonl files in the ~/.claude/projects/ entry for the "
|
|
245
|
+
"~/.s4l-worker directory.\n"
|
|
246
|
+
"Known heal patterns, in order of likelihood: "
|
|
247
|
+
"(1) dead claim — a job in claude-queue/running/ whose claim_pid no longer "
|
|
248
|
+
"exists in ps was orphaned by a killed worker (app quit mid-draft); move its "
|
|
249
|
+
"json back to claude-queue/pending/<its type>/ under the SAME filename with the "
|
|
250
|
+
"claim_pid and claimed_at keys deleted, and the every-minute worker will "
|
|
251
|
+
"re-claim it. "
|
|
252
|
+
"(2) rate-limited — worker transcripts end in 429/limit errors; nothing local "
|
|
253
|
+
"to fix, say so in the report. "
|
|
254
|
+
"(3) schedule missing/disabled — re-register via queue_setup or the host "
|
|
255
|
+
"create_scheduled_task with the prompt from "
|
|
256
|
+
"~/.claude/scheduled-tasks/s4l-worker/SKILL.md.\n"
|
|
257
|
+
"HARD RULE: fix state files only. Never edit, copy, or patch code in "
|
|
258
|
+
"~/.social-autoposter-mcp or any extension bundle (hot patches are auto-reverted "
|
|
259
|
+
"and mask the real bug).\n"
|
|
260
|
+
"Finally — ALWAYS, healed or not — write a short markdown report (symptom, root "
|
|
261
|
+
"cause, actions taken, current state) to "
|
|
262
|
+
"~/.social-autoposter-mcp/diagnostics/report-<UTC-timestamp>.md and run "
|
|
263
|
+
"`~/.social-autoposter-mcp/runtime/.venv/bin/python3 "
|
|
264
|
+
"~/.social-autoposter-mcp/repo/package/scripts/send_diagnostic_report.py "
|
|
265
|
+
"<that file>` so the report reaches the S4L developers. Keep replies short."
|
|
266
|
+
)
|
|
230
267
|
|
|
231
268
|
# A pending draft job older than this (seconds) with nothing claiming it means no
|
|
232
269
|
# routine is draining the queue — the worker would claim within a minute if it
|
|
@@ -517,6 +554,29 @@ class S4LMenuBar(rumps.App):
|
|
|
517
554
|
)
|
|
518
555
|
|
|
519
556
|
|
|
557
|
+
def _diagnose_fix(self, _=None):
|
|
558
|
+
"""Universal ⚠ escape hatch: hand Claude a diagnose-and-heal prompt for
|
|
559
|
+
whatever persistent attention state the menu bar is showing, via the same
|
|
560
|
+
clipboard-prompt flow as Set up / Re-arm. The prompt makes Claude ship a
|
|
561
|
+
report back through send_diagnostic_report.py, so we hear about every
|
|
562
|
+
field failure this button gets used on — the click itself is also
|
|
563
|
+
captured, so \"clicked but no report arrived\" is a visible signal."""
|
|
564
|
+
reason, detail = getattr(self, "_stall_reason_info", ("", "")) or ("", "")
|
|
565
|
+
if not reason:
|
|
566
|
+
sched = getattr(self, "_schedule_state_cache", "") or ""
|
|
567
|
+
reason = f"schedule_{sched}" if sched in ("missing", "disabled") else "unknown"
|
|
568
|
+
_capture_msg(
|
|
569
|
+
"S4L diagnose&fix clicked",
|
|
570
|
+
phase="diagnose_fix",
|
|
571
|
+
reason=reason,
|
|
572
|
+
)
|
|
573
|
+
self._clipboard_prompt(
|
|
574
|
+
DIAGNOSE_PROMPT_TEMPLATE.format(reason=reason, detail=detail or "n/a"),
|
|
575
|
+
"Diagnose & fix S4L in Claude",
|
|
576
|
+
"Claude will diagnose the warning, heal what it safely can, and send "
|
|
577
|
+
"a report to the S4L developers",
|
|
578
|
+
)
|
|
579
|
+
|
|
520
580
|
def _rearm(self, _=None):
|
|
521
581
|
"""Register the draft schedule for the CURRENT account via the host
|
|
522
582
|
create_scheduled_task flow (same as onboarding) — it registers under
|
|
@@ -2409,6 +2469,10 @@ class S4LMenuBar(rumps.App):
|
|
|
2409
2469
|
else:
|
|
2410
2470
|
items.append(self._label("⚠ Draft tasks aren’t scheduled on this account"))
|
|
2411
2471
|
items.append(rumps.MenuItem("Set up draft schedule for this account", callback=self._rearm))
|
|
2472
|
+
# Universal escape hatch for EVERY persistent ⚠ (the draft_stuck and
|
|
2473
|
+
# rate_limited branches previously dead-ended with labels only): hand
|
|
2474
|
+
# Claude a diagnose-and-heal prompt that also reports back to us.
|
|
2475
|
+
items.append(rumps.MenuItem("Diagnose & fix in Claude…", callback=self._diagnose_fix))
|
|
2412
2476
|
items.append(rumps.separator)
|
|
2413
2477
|
|
|
2414
2478
|
if not runtime_ready:
|
package/mcp/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m13v/s4l-mcp",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.204-rc.1",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Desktop MCP client for social-autoposter (X/Twitter rail): manual draft/review/approve loop, autopilot control, and stats. Thin wrapper over the existing pipeline scripts.",
|
|
6
6
|
"license": "MIT",
|
package/package.json
CHANGED
|
@@ -637,6 +637,133 @@ def _env_int(name: str, default: int) -> int:
|
|
|
637
637
|
return default
|
|
638
638
|
|
|
639
639
|
|
|
640
|
+
# A claim whose agent-session pid has been ABSENT from the full ps snapshot for this
|
|
641
|
+
# many consecutive reaper cycles is provably dead — requeue its job. Two cycles (not
|
|
642
|
+
# one) so a single torn/raced ps snapshot can never orphan-then-duplicate a live
|
|
643
|
+
# draft. The 2026-07-06 incident this fixes: quitting the Claude app mid-draft killed
|
|
644
|
+
# the claim-holder; the job sat claimed in running/ for the producer's full 30-minute
|
|
645
|
+
# timeout while the menu bar showed ⚠, even though this reaper logged the dead pid
|
|
646
|
+
# every cycle. Absence from the process table is the kernel's word that the process
|
|
647
|
+
# is gone; the only wrong-verdict risk is ps itself failing, which the empty-snapshot
|
|
648
|
+
# guard below covers (pid REUSE makes a dead pid look alive — never the reverse — so
|
|
649
|
+
# reuse can only delay this path, never mistrigger it).
|
|
650
|
+
STALE_CLAIM_STRIKES = _env_int("S4L_REAPER_STALE_CLAIM_STRIKES", 2)
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def requeue_dead_claims(by_pid, dry=False):
|
|
654
|
+
"""Move running/ jobs whose claim-holder session is provably dead back to
|
|
655
|
+
pending/<type>/ so the next scheduled worker re-claims them within a tick.
|
|
656
|
+
|
|
657
|
+
This is the exact inverse of the atomic claim rename in claude_job.py::cmd_next
|
|
658
|
+
(pending/<type>/<name> -> running/<name>), with the stale claim stamp scrubbed
|
|
659
|
+
so the re-claiming worker stamps fresh. The producer is unaffected: it only
|
|
660
|
+
polls the result path once claimed_at is set, and its timeout still removes
|
|
661
|
+
both the pending and running paths, so a requeued job cannot outlive its
|
|
662
|
+
producer. Strike counts persist in claude-queue/stale-claim-strikes.json.
|
|
663
|
+
Returns the number of jobs requeued. Best-effort: never raises."""
|
|
664
|
+
requeued = 0
|
|
665
|
+
try:
|
|
666
|
+
qroot = os.path.join(_state_dir(), "claude-queue")
|
|
667
|
+
running = os.path.join(qroot, "running")
|
|
668
|
+
strikes_path = os.path.join(qroot, "stale-claim-strikes.json")
|
|
669
|
+
try:
|
|
670
|
+
names = [
|
|
671
|
+
n for n in os.listdir(running)
|
|
672
|
+
if n.endswith(".json") and not n.endswith(".tmp")
|
|
673
|
+
]
|
|
674
|
+
except OSError:
|
|
675
|
+
return 0
|
|
676
|
+
try:
|
|
677
|
+
with open(strikes_path) as f:
|
|
678
|
+
strikes = json.load(f)
|
|
679
|
+
if not isinstance(strikes, dict):
|
|
680
|
+
strikes = {}
|
|
681
|
+
except Exception:
|
|
682
|
+
strikes = {}
|
|
683
|
+
# ps failed / returned nothing: EVERY pid would look dead. Don't strike.
|
|
684
|
+
if not by_pid:
|
|
685
|
+
return 0
|
|
686
|
+
seen = set()
|
|
687
|
+
for name in names:
|
|
688
|
+
path = os.path.join(running, name)
|
|
689
|
+
try:
|
|
690
|
+
with open(path) as f:
|
|
691
|
+
job = json.load(f)
|
|
692
|
+
except Exception:
|
|
693
|
+
continue
|
|
694
|
+
pid = job.get("claim_pid")
|
|
695
|
+
if not isinstance(pid, int) or pid <= 1:
|
|
696
|
+
continue # unstamped claim: nothing to test liveness against
|
|
697
|
+
seen.add(name)
|
|
698
|
+
if pid in by_pid:
|
|
699
|
+
strikes.pop(name, None) # holder alive -> clean slate
|
|
700
|
+
continue
|
|
701
|
+
entry = strikes.get(name)
|
|
702
|
+
if not isinstance(entry, dict) or entry.get("pid") != pid:
|
|
703
|
+
entry = {"pid": pid, "strikes": 0}
|
|
704
|
+
entry["strikes"] = int(entry.get("strikes", 0)) + 1
|
|
705
|
+
strikes[name] = entry
|
|
706
|
+
if entry["strikes"] < STALE_CLAIM_STRIKES:
|
|
707
|
+
continue
|
|
708
|
+
qtype = job.get("type") or "twitter-prep"
|
|
709
|
+
pend_dir = os.path.join(qroot, "pending", str(qtype))
|
|
710
|
+
print(
|
|
711
|
+
f"[claude-reaper] releasing stale claim: job {name} held by dead"
|
|
712
|
+
f" pid {pid} for {entry['strikes']} cycles -> requeue to"
|
|
713
|
+
f" pending/{qtype}",
|
|
714
|
+
file=sys.stderr,
|
|
715
|
+
)
|
|
716
|
+
if dry:
|
|
717
|
+
continue
|
|
718
|
+
try:
|
|
719
|
+
job.pop("claim_pid", None)
|
|
720
|
+
job.pop("claimed_at", None)
|
|
721
|
+
os.makedirs(pend_dir, exist_ok=True)
|
|
722
|
+
dst = os.path.join(pend_dir, name)
|
|
723
|
+
tmp = dst + f".tmp.{os.getpid()}"
|
|
724
|
+
with open(tmp, "w") as f:
|
|
725
|
+
json.dump(job, f)
|
|
726
|
+
os.replace(tmp, dst)
|
|
727
|
+
os.remove(path)
|
|
728
|
+
strikes.pop(name, None)
|
|
729
|
+
requeued += 1
|
|
730
|
+
except Exception as e:
|
|
731
|
+
print(
|
|
732
|
+
f"[claude-reaper] requeue of {name} failed: {e}",
|
|
733
|
+
file=sys.stderr,
|
|
734
|
+
)
|
|
735
|
+
continue
|
|
736
|
+
# Fleet visibility: a released claim means a worker died mid-draft on
|
|
737
|
+
# this install (app quit, crash, logout). Same lane as the reaper's
|
|
738
|
+
# crash telemetry; best-effort.
|
|
739
|
+
try:
|
|
740
|
+
import sentry_init
|
|
741
|
+
sentry_init.init()
|
|
742
|
+
sentry_init.capture_message(
|
|
743
|
+
f"S4L reaper requeued stale claim (dead pid {pid}): {name}",
|
|
744
|
+
level="warning",
|
|
745
|
+
tags={"component": "claude_reaper", "phase": "stale_claim_requeue"},
|
|
746
|
+
)
|
|
747
|
+
sentry_init.flush(2.0)
|
|
748
|
+
except Exception:
|
|
749
|
+
pass
|
|
750
|
+
# Drop strike entries for jobs that left running/ (drained or timed out).
|
|
751
|
+
for name in list(strikes):
|
|
752
|
+
if name not in seen:
|
|
753
|
+
strikes.pop(name, None)
|
|
754
|
+
if not dry:
|
|
755
|
+
try:
|
|
756
|
+
tmp = strikes_path + f".tmp.{os.getpid()}"
|
|
757
|
+
with open(tmp, "w") as f:
|
|
758
|
+
json.dump(strikes, f)
|
|
759
|
+
os.replace(tmp, strikes_path)
|
|
760
|
+
except Exception:
|
|
761
|
+
pass
|
|
762
|
+
except Exception:
|
|
763
|
+
return requeued
|
|
764
|
+
return requeued
|
|
765
|
+
|
|
766
|
+
|
|
640
767
|
def main() -> int:
|
|
641
768
|
dry = "--dry-run" in sys.argv
|
|
642
769
|
max_age = _env_int("S4L_REAPER_MAX_AGE_SEC", DEFAULT_MAX_AGE_SEC)
|
|
@@ -777,6 +904,12 @@ def main() -> int:
|
|
|
777
904
|
file=sys.stderr,
|
|
778
905
|
)
|
|
779
906
|
|
|
907
|
+
# Act on the dead set (not just log it): release claims whose holder has been
|
|
908
|
+
# gone from the process table for STALE_CLAIM_STRIKES consecutive cycles, so a
|
|
909
|
+
# worker killed mid-draft (app quit, crash, logout) costs the producer ~2-3
|
|
910
|
+
# minutes instead of its full 30-minute timeout behind a ⚠ menu bar.
|
|
911
|
+
requeued_claims = requeue_dead_claims(by_pid, dry=dry)
|
|
912
|
+
|
|
780
913
|
live_pids = set(meta.keys())
|
|
781
914
|
|
|
782
915
|
killed = 0
|
|
@@ -843,6 +976,7 @@ def main() -> int:
|
|
|
843
976
|
"disclaimer_killed": disclaimers,
|
|
844
977
|
"macos_mcp_killed": macos_killed,
|
|
845
978
|
"archived_sessions": archived_sessions,
|
|
979
|
+
"requeued_stale_claims": requeued_claims,
|
|
846
980
|
"spared_claim_pids": sorted(claim_pids),
|
|
847
981
|
"worker_probe_seen": stats["worker_probe_seen"],
|
|
848
982
|
"reapable_workers": stats["reapable_workers"],
|
package/scripts/release-mcpb.sh
CHANGED
|
@@ -54,7 +54,9 @@
|
|
|
54
54
|
# bash scripts/release-mcpb.sh --no-release # build + pack + verify only (no npm, no GitHub)
|
|
55
55
|
# bash scripts/release-mcpb.sh --draft # GitHub release as a draft
|
|
56
56
|
# bash scripts/release-mcpb.sh --staging # PRE-release -rc.N (staging channel only)
|
|
57
|
-
# bash scripts/release-mcpb.sh --promote v1.6.193-rc.2 #
|
|
57
|
+
# bash scripts/release-mcpb.sh --promote v1.6.193-rc.2 # BLOCKED for -rc tags: stable must
|
|
58
|
+
# # carry clean digits (user rule, 2026-07-06). Commit + push,
|
|
59
|
+
# # then cut stable with --version X.Y.Z. ALLOW_RC_PROMOTE=1 forces.
|
|
58
60
|
|
|
59
61
|
set -euo pipefail
|
|
60
62
|
|
|
@@ -117,14 +119,28 @@ command -v node >/dev/null || die "node not found on PATH"
|
|
|
117
119
|
# Flip the SAME artifact the staging box tested: clear GitHub's prerelease flag
|
|
118
120
|
# and mark it latest (so releases/latest + the stable boxes pick it up), and move
|
|
119
121
|
# npm's `latest` dist-tag onto it. Byte-for-byte identical to what was tested;
|
|
120
|
-
# there is no repack, so nothing can drift between test and ship.
|
|
121
|
-
#
|
|
122
|
-
#
|
|
122
|
+
# there is no repack, so nothing can drift between test and ship.
|
|
123
|
+
#
|
|
124
|
+
# USER RULE (2026-07-06, after the SECOND in-place rc promote; the first left
|
|
125
|
+
# v1.6.197-rc.16 flagged stable on 2026-07-03): the stable channel must show
|
|
126
|
+
# clean version digits, never an -rc.N label. So promoting an -rc tag in place
|
|
127
|
+
# is BLOCKED by default. Ship the same code with a clean number instead:
|
|
128
|
+
# commit + push (the pack ships the working tree), then
|
|
129
|
+
# `bash scripts/release-mcpb.sh --version X.Y.Z`. Set ALLOW_RC_PROMOTE=1 only
|
|
130
|
+
# for an emergency where a rebuild is riskier than the label.
|
|
123
131
|
if [[ -n "$PROMOTE_TAG" ]]; then
|
|
124
132
|
command -v gh >/dev/null || die "gh CLI not found"
|
|
125
133
|
gh auth status >/dev/null 2>&1 || die "gh not authenticated (run: gh auth login)"
|
|
126
134
|
PTAG="$PROMOTE_TAG"; [[ "$PTAG" == v* ]] || PTAG="v$PTAG"
|
|
127
135
|
PVER="${PTAG#v}"
|
|
136
|
+
if [[ "$PVER" == *-rc* && "${ALLOW_RC_PROMOTE:-0}" != "1" ]]; then
|
|
137
|
+
CLEAN_VER="${PVER%%-rc*}"
|
|
138
|
+
die "stable releases carry clean digits (user rule, 2026-07-06); refusing to promote $PTAG in place.
|
|
139
|
+
Ship the same code under a clean number instead:
|
|
140
|
+
1. commit + push the repo (the pipeline pack ships the working tree)
|
|
141
|
+
2. bash scripts/release-mcpb.sh --version $CLEAN_VER (pick the next patch if $CLEAN_VER is already published)
|
|
142
|
+
Emergency override (ships the -rc label to every stable box): ALLOW_RC_PROMOTE=1 bash scripts/release-mcpb.sh --promote $PTAG"
|
|
143
|
+
fi
|
|
128
144
|
gh release view "$PTAG" -R "$GH_REPO" >/dev/null 2>&1 || die "no release $PTAG to promote"
|
|
129
145
|
say "Promoting $PTAG to stable (in place; same tested artifact, no rebuild)"
|
|
130
146
|
gh release edit "$PTAG" -R "$GH_REPO" --prerelease=false --latest
|
|
@@ -493,7 +509,8 @@ if [[ -n "$DRAFT_FLAG" ]]; then
|
|
|
493
509
|
elif [[ "$DO_STAGING" == "1" ]]; then
|
|
494
510
|
say "Staging pre-release — releases/latest deliberately EXCLUDES it, so stable boxes stay put."
|
|
495
511
|
echo " Only boxes on the staging channel pull $TAG (via the releases LIST endpoint)."
|
|
496
|
-
echo " To ship it to everyone once tested
|
|
512
|
+
echo " To ship it to everyone once tested (stable = clean digits, never -rc): commit + push, then"
|
|
513
|
+
echo " bash scripts/release-mcpb.sh --version ${VERSION%%-rc*} # pick the next patch if that version is already published"
|
|
497
514
|
else
|
|
498
515
|
say "Verifying releases/latest serves $TAG (drives the menu-bar update banner)"
|
|
499
516
|
LATEST_SEEN=""
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Ship a menu-bar "Diagnose & fix" report back to the S4L developers.
|
|
3
|
+
|
|
4
|
+
Usage: send_diagnostic_report.py <report.md> [reason-code]
|
|
5
|
+
|
|
6
|
+
The menu bar's ⚠ "Diagnose & fix in Claude…" item hands Claude a prompt that ends
|
|
7
|
+
with: write a short markdown report of the diagnosis (symptom, root cause, actions
|
|
8
|
+
taken, current state) and run this script on it. We ship it over the same Sentry
|
|
9
|
+
lane the reaper and menu bar already use (sentry_init tags every event with the
|
|
10
|
+
install identity), so field diagnoses land next to the "autopilot needs attention"
|
|
11
|
+
warnings they resolve. The report body rides in the message itself, truncated to
|
|
12
|
+
stay inside Sentry's message limits; the full file stays on disk under
|
|
13
|
+
~/.social-autoposter-mcp/diagnostics/ for follow-up over the QA/SSH lane.
|
|
14
|
+
|
|
15
|
+
Exit codes: 0 shipped, 1 usage / unreadable file, 2 telemetry unavailable (the
|
|
16
|
+
report file still exists locally either way — say so, never lose the diagnosis).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
import sys
|
|
23
|
+
|
|
24
|
+
# 8KB is Sentry's formatted-message ceiling; leave headroom for the header line.
|
|
25
|
+
MAX_BODY_CHARS = 6000
|
|
26
|
+
|
|
27
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def main() -> int:
|
|
31
|
+
if len(sys.argv) < 2:
|
|
32
|
+
print("usage: send_diagnostic_report.py <report.md> [reason-code]", file=sys.stderr)
|
|
33
|
+
return 1
|
|
34
|
+
path = sys.argv[1]
|
|
35
|
+
reason = sys.argv[2] if len(sys.argv) > 2 else "unspecified"
|
|
36
|
+
try:
|
|
37
|
+
with open(path, encoding="utf-8", errors="replace") as f:
|
|
38
|
+
body = f.read().strip()
|
|
39
|
+
except OSError as e:
|
|
40
|
+
print(f"cannot read report file: {e}", file=sys.stderr)
|
|
41
|
+
return 1
|
|
42
|
+
if not body:
|
|
43
|
+
print("report file is empty — write the diagnosis first", file=sys.stderr)
|
|
44
|
+
return 1
|
|
45
|
+
truncated = len(body) > MAX_BODY_CHARS
|
|
46
|
+
if truncated:
|
|
47
|
+
body = body[:MAX_BODY_CHARS] + "\n…[truncated; full report on the box]"
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
import sentry_init
|
|
51
|
+
sentry_init.init()
|
|
52
|
+
sentry_init.capture_message(
|
|
53
|
+
"S4L field diagnosis report\n\n" + body,
|
|
54
|
+
level="warning",
|
|
55
|
+
tags={
|
|
56
|
+
"component": "diagnose_fix",
|
|
57
|
+
"phase": "field_report",
|
|
58
|
+
"reason": reason,
|
|
59
|
+
"truncated": str(truncated).lower(),
|
|
60
|
+
},
|
|
61
|
+
)
|
|
62
|
+
sentry_init.flush(5.0)
|
|
63
|
+
except Exception as e:
|
|
64
|
+
print(
|
|
65
|
+
f"telemetry unavailable ({e}); report kept locally at {path}",
|
|
66
|
+
file=sys.stderr,
|
|
67
|
+
)
|
|
68
|
+
return 2
|
|
69
|
+
print(f"report shipped to S4L telemetry (kept locally at {path})")
|
|
70
|
+
return 0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
if __name__ == "__main__":
|
|
74
|
+
sys.exit(main())
|