Files
pulse-bridge/bridge.py
T
Nirav PatelandClaude Fable 5 e005931cb5 codex review fixes: duplicate-post recovery, fsync, fatal corrupt state, narrower comment drop
- create_post: on HTTPError, look for an existing post tagged ledger:<id>
  (insert-then-error recovery) and adopt it before retrying
- save_state: fsync tmp file before rename
- load_state: corrupt state is fatal (auto-reseed would duplicate every
  open item's post)
- comment: drop only on 500/404/410; 401/403/429/502/503 retry next poll

Accepted as-is (with rationale): same-second ordering relies on the feed
replaying the JSONL in append order + Python's stable sort; seed-window
add+close races reduce to the documented 'predates bridge' skip.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YECpkAwQUwgu7NVy91R8fW
2026-08-22 14:47:34 -04:00

373 lines
14 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""pulse-bridge v0 — mirror the estate's shared work ledger into Pulse posts.
Source: ~/.local/bin/shre-items (JSONL ledger, `feed --since` op stream).
Target: local mib007 (:5520) comms API — the backing store of the Pulse /
Activity feed. One ledger item = one post in the "activity" channel; stage
changes / failures / escalations = threaded comments; close = final comment.
Stdlib only. State (checkpoint + ledger-id -> post-id map) lives in
~/.shre/pulse-bridge/state.json, written atomically (tmp + rename).
"""
import json
import os
import re
import subprocess
import sys
import time
import urllib.error
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
# ── Config (env file, then environment, then defaults) ───────────────────────
HOME = Path.home()
ENV_FILE = Path(os.environ.get("PULSE_BRIDGE_ENV",
Path(__file__).resolve().parent / "bridge.env"))
def _load_env_file(path: Path) -> dict:
conf = {}
if path.exists():
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
conf[k.strip()] = v.strip().strip('"').strip("'")
return conf
_FILE = _load_env_file(ENV_FILE)
def cfg(key: str, default: str) -> str:
return os.environ.get(key) or _FILE.get(key) or default
POLL_SEC = int(cfg("PULSE_BRIDGE_POLL_SEC", "30"))
MIB_BASE = cfg("PULSE_BRIDGE_MIB_BASE", "http://127.0.0.1:5520").rstrip("/")
WORKSPACE_ID = cfg("PULSE_BRIDGE_WORKSPACE_ID",
"293d29db-4978-4c54-a92e-b24d4c0a7115") # Nirlab Command Center
CHANNEL_NAME = cfg("PULSE_BRIDGE_CHANNEL", "activity")
MENTION = cfg("PULSE_BRIDGE_MENTION", "nir") # user handle to @mention
POSTER_NAME = cfg("PULSE_BRIDGE_POSTER_NAME", "Ledger")
SHRE_ITEMS = cfg("PULSE_BRIDGE_SHRE_ITEMS", str(HOME / ".local/bin/shre-items"))
STATE_FILE = Path(cfg("PULSE_BRIDGE_STATE",
str(HOME / ".shre/pulse-bridge/state.json")))
TOKEN_FILE = Path(cfg("PULSE_BRIDGE_TOKEN_FILE",
str(HOME / ".shre/service-tokens.json")))
TOKEN_KEY = cfg("PULSE_BRIDGE_TOKEN_KEY", "mib007")
STAGES = ["queued", "build", "review", "merge", "deploy", "verify"]
def log(msg: str) -> None:
print(f"{datetime.now(timezone.utc).isoformat(timespec='seconds')} {msg}",
flush=True)
# ── mib007 HTTP ──────────────────────────────────────────────────────────────
def _token() -> str:
return json.loads(TOKEN_FILE.read_text(encoding="utf-8"))[TOKEN_KEY]
def api(method: str, path: str, body=None, timeout=15):
req = urllib.request.Request(
f"{MIB_BASE}{path}",
data=json.dumps(body).encode() if body is not None else None,
method=method,
headers={
"Authorization": f"Bearer {_token()}",
"Content-Type": "application/json",
},
)
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read()
return json.loads(raw) if raw else None
# ── State ────────────────────────────────────────────────────────────────────
def load_state() -> dict:
if STATE_FILE.exists():
try:
return json.loads(STATE_FILE.read_text(encoding="utf-8"))
except json.JSONDecodeError:
# Starting fresh here would re-seed every open item -> a duplicate
# post per item. Refuse to run until a human inspects the file.
log(f"FATAL state file corrupt: {STATE_FILE} — fix or remove it "
"deliberately; refusing to reseed automatically")
sys.exit(1)
return {"checkpoint": "", "seen": [], "map": {}, "seeded": False,
"channel_id": ""}
def save_state(state: dict) -> None:
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp = STATE_FILE.with_suffix(".json.tmp")
with open(tmp, "w", encoding="utf-8") as f:
json.dump(state, f, indent=1)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, STATE_FILE)
# ── Content helpers ──────────────────────────────────────────────────────────
def neutralize(text: str) -> str:
"""Insert a zero-width space after '@' in ledger-derived text.
mib007's comms route treats the FIRST '@handle' in a message as an agent
mention: a matching agent fires an AI reply into the thread, and on this
instance ANY mention 500s after the insert (the agent lookup references a
nonexistent url_key column). So no bridge-emitted text may ever contain a
bare '@handle' — including the bridge's own needs-you escalation, which is
emitted zwsp-neutralised and renders identically in the UI.
"""
return (text or "").replace("@", "@")
def tag_line(item: dict) -> str:
bits = [f"#{item.get('kind', 'item')}"]
if item.get("stage"):
bits.append(f"#{item['stage']}")
for t in item.get("tags", []) or []:
bits.append(f"#{t}")
if item.get("surface"):
bits.append(f"surface:{item['surface']}")
if item.get("project"):
bits.append(f"project:{item['project']}")
bits.append(f"ledger:{item['id']}")
return " · ".join(bits)
def post_body(item: dict) -> str:
head = {"needs-you": "🙋", "failed": "⛔", "pipeline": "🔧",
"recommended": "💡", "gap": "🕳"}.get(item.get("kind", ""), "📌")
parts = [f"{head} {neutralize(item.get('title', '(untitled)'))}"]
if item.get("detail"):
parts.append(neutralize(item["detail"]))
parts.append(tag_line(item))
return "\n\n".join(parts)
def update_body(rec: dict) -> str:
lines = []
kind = rec.get("kind")
stage = rec.get("stage")
note = neutralize(rec.get("note", ""))
if kind == "needs-you":
lines.append(neutralize(f"@{MENTION}") + " NEEDS YOU: this item is now"
" waiting on a human." + (f" — {note}" if note else ""))
note = "" # already included
elif kind == "failed":
where = f" at {stage}" if stage else ""
attempt = f", attempt {rec['attempt']}" if rec.get("attempt") else ""
lines.append(f"⛔ failed{where}{attempt}" + (f" — {note}" if note else ""))
note = ""
else:
if kind:
lines.append(f"kind → {kind}")
if stage:
lines.append(f"stage → {stage}")
if rec.get("attempt") and not kind:
lines.append(f"attempt {rec['attempt']}")
if rec.get("detail"):
lines.append(f"detail updated: {neutralize(rec['detail'])}")
if note:
lines.append(note)
return "\n".join(lines) or "(updated)"
def close_body(rec: dict) -> str:
why = neutralize(rec.get("why", ""))
if rec.get("status") == "dropped":
return "🗑 dropped" + (f" — {why}" if why else "")
return "✅ done" + (f" — {why}" if why else "")
# ── Ledger access ────────────────────────────────────────────────────────────
def shre_items(*args: str) -> str:
out = subprocess.run([SHRE_ITEMS, *args], capture_output=True, text=True,
timeout=30)
if out.returncode != 0:
raise RuntimeError(f"shre-items {' '.join(args)} rc={out.returncode}: "
f"{out.stderr.strip()[:300]}")
return out.stdout
def feed_since(checkpoint: str) -> list:
args = ["feed"] + (["--since", checkpoint] if checkpoint else [])
records = []
for line in shre_items(*args).splitlines():
line = line.strip()
if not line:
continue
try:
records.append(json.loads(line))
except json.JSONDecodeError:
continue
records.sort(key=lambda r: r.get("at", ""))
return records
def open_items() -> list:
return json.loads(shre_items("list", "--json") or "[]")
# ── Pulse actions ────────────────────────────────────────────────────────────
def ensure_channel(state: dict) -> str:
if state.get("channel_id"):
return state["channel_id"]
channels = api("GET", f"/api/workspaces/{WORKSPACE_ID}/comms/channels") or []
for ch in channels:
if (ch.get("name") or "").lower() == CHANNEL_NAME.lower():
state["channel_id"] = ch["id"]
return ch["id"]
ch = api("POST", f"/api/workspaces/{WORKSPACE_ID}/comms/channels",
{"name": CHANNEL_NAME, "description": "Workspace activity feed",
"type": "public"})
state["channel_id"] = ch["id"]
return ch["id"]
def send_message(channel_id: str, content: str, thread_id=None) -> dict:
body = {"content": content, "type": "text", "userName": POSTER_NAME}
if thread_id:
body["threadId"] = thread_id
return api("POST",
f"/api/workspaces/{WORKSPACE_ID}/comms/channels/{channel_id}/messages",
body)
def find_existing_post(state: dict, ledger_id: str):
"""Recovery lookup: the target API can insert a row and then fail, so a
retried create must first check whether a post tagged ledger:<id> already
exists. Bounded to the most recent 200 channel messages."""
try:
msgs = api("GET", f"/api/workspaces/{WORKSPACE_ID}/comms/channels/"
f"{ensure_channel(state)}/messages?limit=200") or []
except Exception:
return None
needle = f"ledger:{ledger_id}"
for m in msgs:
if not m.get("thread_id") and needle in (m.get("content") or ""):
return m.get("id")
return None
def create_post(state: dict, item: dict) -> None:
if item["id"] in state["map"]:
return # already mirrored (seed/feed overlap)
try:
msg = send_message(ensure_channel(state), post_body(item))
except urllib.error.HTTPError:
# The insert may have landed before the error (proven insert-then-500
# behaviour). Adopt an existing post instead of duplicating on retry.
existing = find_existing_post(state, item["id"])
if existing:
state["map"][item["id"]] = existing
log(f"post {item['id'][:8]} -> adopted existing {existing}")
return
raise # genuinely not created; retry next poll
state["map"][item["id"]] = msg["id"]
log(f"post {item['id'][:8]} -> {msg['id']} ({item.get('title', '')[:60]})")
def comment(state: dict, ledger_id: str, content: str, what: str) -> None:
post_id = state["map"].get(ledger_id)
if not post_id:
log(f"SKIP {what} {ledger_id[:8]}: no mapped post (predates bridge)")
return
try:
msg = send_message(ensure_channel(state), content, thread_id=post_id)
except urllib.error.HTTPError as e:
# 500: mib007 comms can fail AFTER the insert (proven mention-path
# crash) — retrying risks duplicates, so drop. 404/410: the post is
# gone — retrying can never succeed. Everything else (401/403/429/
# 502/503) is safe to retry next poll.
if e.code in (500, 404, 410):
log(f"DROP {what} {ledger_id[:8]}: HTTP {e.code} "
"(comment not retried)")
return
raise
log(f"{what:6} {ledger_id[:8]} -> comment {msg['id']}")
# ── Main loop ────────────────────────────────────────────────────────────────
def seed(state: dict) -> None:
"""First run: mirror currently-OPEN items only, then start the op stream
from 'now'. Historic adds/closes are deliberately not replayed."""
checkpoint = datetime.now(timezone.utc).isoformat(timespec="seconds")
items = open_items()
log(f"seeding {len(items)} open items")
for item in items:
try:
create_post(state, item)
except Exception as e: # keep seeding the rest
log(f"ERROR seeding {item.get('id', '?')[:8]}: {e}")
state["seeded"] = True
state["checkpoint"] = checkpoint
save_state(state)
def process(state: dict, rec: dict) -> None:
op, lid = rec.get("op"), rec.get("id")
if not lid or op not in ("add", "update", "close"):
return
if op == "add":
create_post(state, rec)
elif op == "update":
comment(state, lid, update_body(rec), "update")
else:
comment(state, lid, close_body(rec), "close")
def poll(state: dict) -> None:
records = feed_since(state["checkpoint"])
seen = {tuple(s) for s in state.get("seen", [])}
new = [r for r in records
if (r.get("id"), r.get("op"), r.get("at")) not in seen]
for rec in new:
try:
process(state, rec)
except Exception as e:
log(f"ERROR {rec.get('op')} {str(rec.get('id'))[:8]}: {e}")
# don't advance past a failed record's timestamp; retry next poll
break
seen.add((rec.get("id"), rec.get("op"), rec.get("at")))
state["checkpoint"] = max(state["checkpoint"], rec.get("at", ""))
# keep only boundary-second entries for the inclusive-window dedupe
state["seen"] = [list(t) for t in seen if t[2] >= state["checkpoint"]]
save_state(state)
def main() -> int:
log(f"pulse-bridge v0 starting: mib={MIB_BASE} ws={WORKSPACE_ID} "
f"channel={CHANNEL_NAME} poll={POLL_SEC}s state={STATE_FILE}")
state = load_state()
if not state.get("seeded"):
try:
seed(state)
except Exception as e:
log(f"FATAL seed failed: {e}")
time.sleep(POLL_SEC)
return 1 # launchd KeepAlive restarts us
while True:
try:
poll(state)
except Exception as e:
log(f"ERROR poll: {e}")
time.sleep(POLL_SEC)
if __name__ == "__main__":
sys.exit(main())