summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-22 13:51:06 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-22 13:51:06 +0800
commit75523a0f99ad86fd5cd01ef36f2091e6b670bf9d (patch)
tree57b3111996de2c48f94b42fe8cdbc8dff2802f9b
parentecae6f77ade52c6c2f4d008dc3d1ce3e338a1e0e (diff)
downloadfund-lab-75523a0f99ad86fd5cd01ef36f2091e6b670bf9d.tar.gz
Add quiet once-only completion notifier skill script (3d-37)
-rwxr-xr-xscripts/notify_completion.py225
1 files changed, 225 insertions, 0 deletions
diff --git a/scripts/notify_completion.py b/scripts/notify_completion.py
new file mode 100755
index 0000000..cc30ee4
--- /dev/null
+++ b/scripts/notify_completion.py
@@ -0,0 +1,225 @@
+#!/usr/bin/env python3
+"""Quiet, once-only completion notifier for the Fund Lab supervision workflow.
+
+Reads a notices directory and, when a marker-stamped ``completion-notice.md`` is
+present, sends its body to a Slack webhook exactly once and then renames the file
+so subsequent runs stay silent. Everything here is stdlib-only and single-file.
+
+Marker contract
+---------------
+The notice file must start with a line of the form ``# NOTICE <sha256>``. The
+hash covers the file content *excluding* the marker line. When the stored marker
+does not match the body the script rewrites the marker (first run establishes it)
+and still sends. After sending, the file is renamed to
+``completion-notice.sent-<timestamp>.md``.
+
+Environment
+-----------
+NOTICES_DIR notices directory (default: /home/somhairle/.hermes/cache/fundlab-notices)
+SLACK_WEBHOOK_URL Slack incoming webhook; when unset/empty the body is not sent
+ and no output is produced, but the file is still marked as sent.
+
+Exit codes
+----------
+0 nothing to do, or delivered (quiet, no stdout)
+2 file read / marker rewrite / send / rename failure (message on stderr)
+"""
+
+import hashlib
+import json
+import os
+import re
+import sys
+import tempfile
+import urllib.request
+from datetime import datetime, timezone
+
+DEFAULT_NOTICES_DIR = "/home/somhairle/.hermes/cache/fundlab-notices"
+NOTICE_NAME = "completion-notice.md"
+SENT_PREFIX = "completion-notice.sent-"
+MARKER_RE = re.compile(r"^# NOTICE ([0-9a-fA-F]{64})$")
+WEBHOOK_TIMEOUT_SECONDS = 5
+
+
+def body_hash(body: str) -> str:
+ """sha256 of the notice body (file content with the marker line removed)."""
+ return hashlib.sha256(body.encode("utf-8")).hexdigest()
+
+
+def render_notice(digest: str, body: str) -> str:
+ return "# NOTICE %s\n%s" % (digest, body)
+
+
+def split_notice(content: str):
+ """Return (marker_hash, body) when the first line is a marker, else None.
+
+ The body keeps everything after the marker line byte-for-byte, including
+ trailing newlines, so the hash is stable across reads.
+ """
+ lines = content.splitlines(keepends=True)
+
+ if not lines:
+ return None
+
+ match = MARKER_RE.match(lines[0].rstrip("\r\n"))
+
+ if match is None:
+ return None
+
+ return match.group(1).lower(), "".join(lines[1:])
+
+
+def send_slack(url: str, text: str) -> None:
+ payload = json.dumps({"text": text}).encode("utf-8")
+
+ request = urllib.request.Request(
+ url,
+ data=payload,
+ headers={"Content-Type": "application/json"},
+ method="POST",
+ )
+
+ with urllib.request.urlopen(request, timeout=WEBHOOK_TIMEOUT_SECONDS) as response:
+ response.read()
+
+
+def sent_target(notices_dir: str) -> str:
+ stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
+ target = os.path.join(notices_dir, "%s%s.md" % (SENT_PREFIX, stamp))
+
+ if os.path.exists(target):
+ target = "%s-%s.md" % (target[:-3], os.getpid())
+
+ return target
+
+
+def process_notice(notices_dir: str, send) -> bool:
+ """Deliver a pending notice once. Returns True when a notice was delivered."""
+ path = os.path.join(notices_dir, NOTICE_NAME)
+
+ if not os.path.isfile(path):
+ return False
+
+ with open(path, "r", encoding="utf-8") as handle:
+ content = handle.read()
+
+ parsed = split_notice(content)
+
+ if parsed is None:
+ return False
+
+ marker, body = parsed
+ digest = body_hash(body)
+
+ if digest != marker:
+ with open(path, "w", encoding="utf-8") as handle:
+ handle.write(render_notice(digest, body))
+
+ url = (os.environ.get("SLACK_WEBHOOK_URL") or "").strip()
+
+ if url:
+ send(url, body)
+
+ os.replace(path, sent_target(notices_dir))
+ return True
+
+
+def _report(name: str, ok: bool, failures: list) -> None:
+ if ok:
+ print("PASS %s" % name)
+ else:
+ print("FAIL %s" % name)
+ failures.append(name)
+
+
+def selftest() -> int:
+ failures = []
+ saved_webhook = os.environ.get("SLACK_WEBHOOK_URL")
+ os.environ["SLACK_WEBHOOK_URL"] = "https://example.invalid/selftest"
+
+ try:
+ with tempfile.TemporaryDirectory(prefix="notify-selftest-") as tmp:
+ # 1) no notice at all -> silent, nothing sent, nothing renamed
+ sent = []
+ result = process_notice(tmp, lambda url, text: sent.append((url, text)))
+ _report(
+ "no notice is silent",
+ result is False and sent == [] and os.listdir(tmp) == [],
+ failures,
+ )
+
+ # 2) healthy marker -> body sent once, file renamed, replay silent
+ body = "标题\n正文 100%\n"
+ path = os.path.join(tmp, NOTICE_NAME)
+
+ with open(path, "w", encoding="utf-8") as handle:
+ handle.write(render_notice(body_hash(body), body))
+
+ sent = []
+ result = process_notice(tmp, lambda url, text: sent.append((url, text)))
+ files = sorted(os.listdir(tmp))
+ renamed = any(name.startswith(SENT_PREFIX) and name.endswith(".md") for name in files)
+ replay = process_notice(tmp, lambda url, text: sent.append((url, text)))
+
+ _report(
+ "notice is sent and renamed once",
+ result is True
+ and sent == [("https://example.invalid/selftest", body)]
+ and NOTICE_NAME not in files
+ and renamed
+ and replay is False
+ and len(sent) == 1,
+ failures,
+ )
+
+ # 3) broken marker -> repaired, then sent and renamed
+ body = "损坏 marker\n"
+ path = os.path.join(tmp, NOTICE_NAME)
+ repaired = render_notice(body_hash(body), body)
+
+ with open(path, "w", encoding="utf-8") as handle:
+ handle.write(render_notice("0" * 64, body))
+
+ sent = []
+ result = process_notice(tmp, lambda url, text: sent.append((url, text)))
+ files = sorted(os.listdir(tmp))
+ corrected = [
+ name
+ for name in files
+ if name.startswith(SENT_PREFIX)
+ and open(os.path.join(tmp, name), "r", encoding="utf-8").read() == repaired
+ ]
+
+ _report(
+ "broken marker is repaired and sent",
+ result is True
+ and sent == [("https://example.invalid/selftest", body)]
+ and len(corrected) == 1
+ and NOTICE_NAME not in files,
+ failures,
+ )
+ finally:
+ if saved_webhook is None:
+ os.environ.pop("SLACK_WEBHOOK_URL", None)
+ else:
+ os.environ["SLACK_WEBHOOK_URL"] = saved_webhook
+
+ return 0 if not failures else 1
+
+
+def main(argv) -> int:
+ if "--selftest" in argv:
+ return selftest()
+
+ notices_dir = (os.environ.get("NOTICES_DIR") or "").strip() or DEFAULT_NOTICES_DIR
+
+ try:
+ process_notice(notices_dir, send_slack)
+ return 0
+ except Exception as error: # noqa: BLE001 - classification is by exit code
+ print("notify_completion: %s" % error, file=sys.stderr)
+ return 2
+
+
+if __name__ == "__main__":
+ sys.exit(main(sys.argv[1:]))