Compare commits

..

10 commits

7 changed files with 742 additions and 18 deletions

147
HANDOFF.md Normal file
View file

@ -0,0 +1,147 @@
# HANDOFF BRIEF — djg-oracle-sl Buzz + sites deployment
**Audience:** senior engineers taking over this stack. Written by the ZCode
agent that built it, including its own mistakes. Trust nothing unverified;
every claim below was tested at least once unless marked UNVERIFIED.
---
## 1. System inventory (single VM: djg-oracle-sl)
- Oracle Cloud ARM aarch64, 4c/23GB, OL8.10, public IP **144.24.30.131**.
SELinux **enforcing**. cgroup **v1** (blocks k3s ≥1.35 — do NOT upgrade k3s
past 1.34 without a cgroup-v2 reboot plan; a reboot also kills any agent
session running on the box).
- **k3s v1.34.11** (pinned): Traefik edge (hostNetwork 80/443), cert-manager
(Let's Encrypt DNS-01 via Cloudflare token), static-site nginx Deployments,
agent-control/pair-relay bridges. kubectl only via
`sudo /usr/local/bin/k3s kubectl` (sudo secure_path drops /usr/local/bin).
- **docker compose stack** for Buzz relay: relay + postgres + redis + Silo
(MinIO fork, digest-pinned). Compose dir: /home/opc/buzz/deploy/compose
(.env = secrets, 600).
- **firewalld DISABLED** (it silently rejected pod-forwarded traffic — this
caused months of hidden "pod egress dead" behavior on the old install).
OCI security list (22/80/443) is the only perimeter.
- **coredns Corefile patched** to `forward . 1.1.1.1 8.8.8.8` because OCI's
169.254.169.254 resolver is link-local and unreachable from pods. k3s
upgrades can revert this — first thing to check when pods can't resolve.
- Docker daemon enabled; coexists with flannel fine (verified after enabling).
## 2. Live services
| What | Where | How reached |
|---|---|---|
| Buzz relay | docker compose, host port 10.0.0.215:3000 | k8s Service(no selector)+Endpoints → Traefik |
| pair-relay | k8s Deployment (image's own binary), port 5000 | Traefik PathPrefix(/pair) |
| agent-control | systemd (system, User=opc), 10.0.0.215:8095 | k8s Service(no selector)+Endpoints → Traefik |
| buzz listener (zai presence/spool) | systemd buzz-listener.service | n/a (writes local spool) |
| hermes-gateway | systemd **user** unit (opc, linger on) | polls relay directly |
| Web client | static-bzc Deployment (hostPath docroot) | client.ch4t.buzz |
| Sites | static-bdj / static-hts / static-adhd Deployments | bestdadjokes.lol / helpme.tips / myadhd.dev |
Domains: ch4t.buzz (relay, /pair, agent control), client.ch4t.buzz (web),
bestdadjokes.lol, helpme.tips, myadhd.dev. Wildcard A * exists for ch4t.buzz.
DNS via Cloudflare API; token at ~/.cloudflare-token (600).
## 3. Secrets & keys map (all 600, none committed)
- /home/opc/.cloudflare-token — CF API token (zones: bestdadjokes.lol,
myadhd.dev, helpme.tips, ch4t.buzz)
- /home/opc/buzz-agent-key.txt — zai identity (relay admin)
- /home/opc/buzz-hermes-key.txt, buzz-antigravity-key.txt,
buzz-openfang-key.txt — staged harness identities (relay members)
- /home/opc/buzz-owner-key.txt — RETIRED bootstrap identity (03dcf395 was
removed from the roster; file is stale — delete or ignore)
- /home/opc/buzz/deploy/compose/.env — relay secrets (relay private key,
DB/Redis/S3, RELAY_OWNER_PUBKEY=Dahjah's npub hex)
- /home/opc/.hermes/.env — hermes secrets + GATEWAY_ALLOW_ALL_USERS=true
- /home/opc/.buzz-pending.json — reactions awaiting cleanup (listener state)
## 4. Known issues, ranked (what to look at first)
### A. The "agent is working" signals are cosmetic (by design, needs redesign)
buzz-listener emits 👀 receipts, 💬 reactions, kind-20002 typing events, and
presence — all from the listener process, which runs 24/7 regardless of
whether the agent (the ZCode session) will ever respond. The agent only wakes
when the ZCode harness delivers the task notification, which works only while
the session is open, and even then delivery latency varies. The user correctly
identified this: green dot + typing ≠ agent working. Options: tie signals to a
real work queue with agent acks, or move to an always-on runtime (Hermes).
### B. zai's responsiveness is session-gated
The agent exists only inside the ZCode session. Session closed/backgrounded →
messages spool but no replies until resume. If 24/7 response is required,
Hermes (installed, awaiting LLM key via `hermes setup --portal`) is the
candidate — its gateway is fully wired.
### C. buzz-listener restart cycle
Listener exits every 2 min by design (zombie-socket mitigation: the relay
doesn't ping, and Node's built-in WS gave no close frame when the relay
silently dropped a connection — observed once, causing hours of missed
messages). systemd Restart=always covers it, and a `since`-based replay on
reconnect covers gaps. But: no TCP keepalive/app ping, and the 5s restart gap
drops nothing only because of the replay. A proper fix = real WS ping/pong
liveness. Also: reactions dedupe via /home/opc/.buzz-reacted (file, append-only).
### D. SELinux labels are load-bearing (OL8 enforcing)
- systemd services executing home-directory binaries → 203/EXEC denied.
Fixed by semanage fcontext: hermes venv + uv python → bin_t (persistent).
The buzz venv (/home/opc/buzz/target/release/buzz) is /usr/local/bin-buzz —
verify context if EXEC issues appear after relabels.
- User services (systemctl --user + linger) are the hermes-docs-recommended
path and avoid the issue entirely.
### E. hermes-gateway flapping history
Root causes fixed in order: no profile for the identity (set-profile), missing
cli_path on PATH (symlinked /home/opc/bin/buzz), missing
GATEWAY_ALLOW_ALL_USERS=true env. If it flaps again: ~/.hermes/logs/gateway.log
has the real logs (journald often shows nothing for the user unit).
### F. Upstream drift
- block/buzz moves fast (relay image :main, unpinned). Our compose pins the
relay image but the k8s Deployments pull :main at deploy time — pin by
digest when comfortable.
- MinIO → Silo migration done (digest-pinned); upstream block/buzz still
references quay.io MinIO — if they migrate, mirror their choice.
## 5. Debugging playbook (commands that actually work)
```bash
# relay health + NIP-11
curl -s -H 'Accept: application/nostr+json' https://ch4t.buzz/
# roster / membership (DB-level, bypasses auth)
sudo docker exec buzz-prod-relay-1 buzz-admin list-members
sudo docker exec buzz-prod-relay-1 buzz-admin add-member --pubkey <hex> --role member
# zai CLI (binary named buzz; release build at /home/opc/buzz/target/release/buzz)
export BUZZ_RELAY_URL=https://ch4t.buzz BUZZ_PRIVATE_KEY=$(grep '^SECRET:' /home/opc/buzz-agent-key.txt | awk '{print $2}')
buzz channels list && buzz messages get --channel <uuid> --limit 10
# gateway logs (user unit writes its own file)
tail -50 /home/opc/.hermes/logs/gateway.log
# sites: static only, docroots under /home/opc/zai-home-base/sites/, deploys via per-project deploy.sh
```
## 6. Where the previous agent (zai) made mistakes — verify, don't trust
1. Claimed fixes after verifying the wrong layer (twice): "CSS fixed" (stylesheet
wasn't linked; caught by the user), "DB purged" (heredoc piped into
`docker exec` without -i — psql got empty stdin, exited silently, looked
like success; caught by the user). Rule: validate at the layer the user sees.
2. Orphaned channel ownership: created a channel with a throwaway key, lost the
key through a migration, left the channel ownerless → the UI had no buttons
for rename/delete/transfer → DB surgery was the only path. Prevention: create
channels with identities whose keys live in files you control.
3. Key generation via openssl produced scalars without leading-zero padding →
invalid 62-char keys, silent mismatches. Use `buzz-admin generate-key`.
4. Multiple silent no-op edits: python str.replace with unverified anchors
returned unchanged strings while logging success. Assert every anchor.
5. The agent shell dies if its cwd is deleted (recreate the path to recover).
## 7. Open items (not started)
- LLM key for hermes (user: `hermes setup --portal`)
- antigravity + openfang harness connections (keys staged, guide in
/home/opc/zai-home-base/buzz/onboarding.md)
- dadjokes.guru + raspiinthesky.com fleet builds (design notes in repo)
- helpme.tips ad integration (blocked on the user's GAM wrapper spec)
- Monitoring: none exists. Presence dot is the only health signal. A dead-man's
switch on the listener would be the first thing to add.

126
SITES-HANDOFF.md Normal file
View file

@ -0,0 +1,126 @@
# SITES HANDOFF — bestdadjokes.lol, helpme.tips, raspiinthesky.com
**Audience:** whoever continues the sites + monetization track. Infrastructure
handoff is in HANDOFF.md (same repo) — this doc covers the three sites only.
Written by the ZCode agent that built them. Verification commands included
throughout; re-run them rather than trusting any claim.
---
## 1. Shared platform (read first)
All three sites are static builds served by the same edge:
- **Generator pattern**: each site has `build.py` (Python 3.6 stdlib only —
no f-string `=` specifiers, no walrus) that renders content JSON into static
HTML into `site/`, then `deploy.sh` rsyncs it in place to
`/home/opc/zai-home-base/sites/<site>/` (NEVER swap the directory — the
nginx pod mounts the docroot by hostPath and an atomic swap breaks it).
- **Edge**: Traefik (k3s, hostNetwork) on 80/443, cert-manager + Let's Encrypt
DNS-01 via the Cloudflare API token (~/.cloudflare-token, 600 perms).
Cloudflare proxies all domains (orange cloud).
- **Ad integration**: slots are marked `<!-- AD_SLOT_* -->` in the build
templates. Nothing is wired — the user's GAM wrapper spec was never
delivered. When it lands: inject the wrapper script + slot divs per the
user's MCM/AdX spec, redeploy, then the user onboards the domain to their
MCM stack.
- **Verification rule** (learned the hard way): validate at the layer the user
sees. The CLI's `messages get`/channel listing HIDES tombstoned events and
the static build can look fine while the live site is broken — always curl
the public URL and check rendered content, not just the build output.
Per-site deploys:
```
cd /home/opc/zai-home-base/bestdadjokes && ./deploy.sh # wait: dir is bdj/
cd /home/opc/zai-home-base/bdj && ./deploy.sh # bestdadjokes.lol
cd /home/opc/zai-home-base/helpme-tips && ./deploy.sh # helpme.tips
```
---
## 2. bestdadjokes.lol — fully operational, zero revenue so far
**Live:** https://bestdadjokes.lol (plus welcome-everyone channel on Buzz for
its audience thread). Cloudflare proxied, Let's Encrypt TLS, edge = Traefik,
origin = static-nginx pod.
**Content (as of handoff):**
- 322 jokes in `bdj/content/jokes.json` — APPEND-ONLY (ids = array index;
the web client's share feature and the generator reference ids)
- 14 category pages + 9 keyword topic pages + generator + joke-of-the-day
(with RSS) + search + about/contact/privacy/terms (privacy has the
Google ad-cookie language required for AdSense/MCM review)
- Changelog: every content/schema change is a git commit; subagent QA passes
ran 3 times (full crawl + rendered-content checks)
**Known issues (open):**
- None open. Historic: two missing-stylesheet bugs (build template lacked the
<link> — caught by the user and by a QA subagent), a broken pager link,
a DB-write-no-op that silently skipped a purge (see HANDOFF.md §6).
**Monetization status: NOTHING LIVE.** Strategy (agreed with the user):
1. Organic SEO baseline (currently zero traffic — domain is fresh)
2. Social distribution — BLOCKED on user providing social accounts
3. MCM/AdX onboarding — BLOCKED on user's GAM service account + wrapper spec
4. Funded traffic tests — BLOCKED on traffic-buying account + budget
The ad slots are in the templates; integration is a config paste when the
wrapper spec arrives.
**Pending work queue:**
- Content expansion (+10 jokes/week cadence was planned, not ongoing)
- Topic-page intros enrichment (9 pages have thin intros)
- GSC submission (blocked on user creating the property)
- dadjokes.guru sister build (design only)
---
## 3. helpme.tips — live, awaiting ad integration
**Live:** https://helpme.tips (Cloudflare proxied, TLS, same edge).
Cloudflare DNS records created via API; the zone is in the CF token scope.
**Content:**
- 8 articles × 3 pages = 24 pages (kitchen/sleep/coffee/habits/home/tech/
groceries/fitness), about/contact/privacy/terms with Google ad language
- Ad slots marked `<!-- AD_SLOT_* -->` in the build template
- Deploy: `cd /home/opc/zai-home-base/helpme-tips && ./deploy.sh`
(build → in-place rsync → origin smoke checks)
**Known issues:** the QA subagent fixed a missing stylesheet link and a
pager bug pre-launch; full 30-page crawl passed after. Nothing open.
**Monetization status:** this is the arbitrage-ready property (modeled on
bodyweightbuzz.com — see the arbitrage-play memory). The strategy:
MCM/AdX onboarding → ad tags via the user's wrapper → organic baseline →
funded traffic tests. BLOCKED on the same user unlocks as bestdadjokes.
**Pending:**
- Ad integration when the wrapper spec arrives
- Content batch #2 (+10 articles) — planned, not started
---
## 4. raspiinthesky.com — NOT STARTED
Domain owned, in Cloudflare (zone in the token scope — verify with the
zones API if in doubt). No content, no deploy, no design. Queued as a
tech/Pi-niche content site using the same generator pattern (copy the
helpme-tips structure, new content JSON + branding).
---
## 5. Revenue reality check (the user's actual goal)
The user's target: $24k/month, which was reframed as one-time debt payoff,
then as ongoing. Current revenue: **$0** across all three. The dependency
chain to first revenue:
1. User: GAM service account (they offered it — never delivered)
2. User: MCM onboarding of helpme.tips + bestdadjokes.lol
3. Agent: ad wrapper integration (slots exist; spec pending)
4. User: traffic-buying account + float for funded tests
5. Agent: run tests, measure, scale or kill
Until 12 land, the only revenue paths are social distribution (blocked on
social accounts) and organic SEO (months). Be honest about this if asked —
the user has repeatedly preferred honest "nothing works yet" over optimism.

118
buzz/ARCHITECTURE.md Normal file
View file

@ -0,0 +1,118 @@
# Buzz architecture on djg-oracle-sl
Everything runs on one Oracle Cloud ARM VM (4 cores, 23 GB RAM, Oracle Linux 8),
public IP 144.24.30.131. Two process worlds coexist: a **k3s cluster** (websites,
edge, cert-manager, pair sidecar, agent-control bridge) and a **docker compose
stack** (the Buzz relay itself). Cloudflare fronts both domains.
## Request path (browser/phone → relay)
```
Phone / Desktop / Browser
→ Cloudflare edge (TLS, proxied DNS)
→ VM :443 (Traefik, k3s DaemonSet, hostNetwork — the ONLY thing on 80/443)
→ Traefik routes by host+path:
ch4t.buzz → buzz-relay Service → Endpoints 10.0.0.215:3000 → docker-proxy → relay:3000
ch4t.buzz/pair → buzz-pair Service → Endpoints 10.0.0.215:5001 → docker-proxy → pair-relay:5000
client.ch4t.buzz → static-bzc Service → nginx pod (buzz-web build via hostPath)
ac.ch4t.buzz → buzz-agent-control → Endpoints 10.0.0.215:8095 → node agent-control (systemd)
```
TLS certs come from cert-manager (Let's Encrypt DNS-01 via the Cloudflare API
token), stored as k8s Secrets, mounted by Traefik.
## The relay stack (docker compose, /home/opc/buzz/deploy/compose)
- **buzz-relay** (`ghcr.io/block/buzz:main`, arm64): WS + REST relay. Auth:
NIP-42 (WS) / NIP-98 (REST), membership enforced. Binds 10.0.0.215:3000
(host-private IP; the k8s Endpoints object bridges Traefik to it).
- **postgres**: events, thread metadata, channel/relay membership.
- **redis**: pub/sub + presence keys (TTL 180s — presence = set-presence
heartbeat every 60s).
- **Silo** (MinIO fork, pinned by digest): Blossom media storage.
- Config in `.env` (chmod 600): relay private key, DB/Redis/S3 secrets,
RELAY_OWNER_PUBKEY (Dahjah), CORS origins (ch4t.buzz + client.ch4t.buzz).
## Identity model (relay roster)
| Identity | Pubkey | Role | Key location |
|---|---|---|---|
| Dahjah (owner) | 9a3b0ff6… | owner | user's own client |
| zai (me) | 4abe5fcd… | admin | /home/opc/buzz-agent-key.txt |
| hermes | b2f52981… | member | /home/opc/buzz-hermes-key.txt |
| antigravity | 52d649db… | member | /home/opc/buzz-antigravity-key.txt |
| openfang | 97c70de2… | member | /home/opc/buzz-openfang-key.txt |
Buzz's built-in relay-bootstrapped agents (not agent-created): Fizz
(e9a8294a…), Pollen (e268df83…), Honey — created by the relay at first boot
as its default example agents; Fizz is driven by the desktop's local ACP
harness using the desktop's own model backend.
## zai's connection (the ZCode session)
I am NOT a standalone daemon — I exist inside the user's ZCode session. My
parts:
1. **systemd `buzz-listener`** (always on): raw WebSocket to the relay with
NIP-42 auth as the zai key. On every kind-9 message from another member:
spools the event to /home/opc/.buzz-inbox, emits the 👀 receipt reaction
(NIP-25 kind 7 via `buzz reactions add`), publishes kind-20002 typing
events for that channel (Buzz's documented working-state fallback for
remote harnesses), and re-publishes presence (`buzz users set-presence
online`) every 60s. Self-heals: exits on socket death, Restart=always.
2. **Inbox poller** (a ZCode background task): polls the inbox spool every
20s; on a new line it exits, which wakes the agent (me) via ZCode's task
notification. I then read the message and reply in-thread via
`buzz messages send`, and restart the poller.
3. **Response path**: replies are posted by shelling out to the buzz CLI
(`buzz messages send --channel … --reply-to …`), NIP-98-signed with my key.
**The honest limitation**: step 1's reactions/typing/presence fire 24/7
(listener is a daemon), but step 2's wake only works while the ZCode session
is open. So the signals show "listener alive", not "agent currently
processing" — the user's correctly-identified gap.
## Hermes' connection (the 24/7 agent)
Hermes is a real daemon on the VM — no ZCode session needed:
- Installed at ~/.hermes (Hermes Agent v0.21.3, uv-managed Python 3.11).
- Gateway config: ~/.hermes/config.yaml `gateway.platforms.buzz` — relay
https://ch4t.buzz, cli_path to the buzz binary, all joined channels
(no restriction), home_channel = the new #general uuid, require_mention
true (responds when @-addressed), dm_policy/group_policy open.
- Credentials: ~/.hermes/.env holds BUZZ_PRIVATE_KEY (hermes' staged key) and
GATEWAY_ALLOW_ALL_USERS=true.
- Runs as a systemd **user** service (`hermes-gateway`, linger enabled for
opc) — a SYSTEM service was tried first but SELinux denies confined
services executing home-directory venv binaries (203/EXEC); the user
service domain allows it. SELinux labels: the buzz venv + uv python trees
are semanage-labeled bin_t persistently.
- Missing piece: an LLM provider key. Without it Hermes receives messages and
posts its built-in "couldn't connect to model service" notice instead of a
real response. Set via `hermes setup --portal` (interactive) or
`hermes config set <PROVIDER>_API_KEY <key>`.
## Other components
- **buzz-pair-relay** (in the relay image, runs as a k8s Deployment):
ephemeral NIP-AB device-pairing matcher at wss://ch4t.buzz/pair — the
desktop's mobile-pairing QR points here. Its absence was the original
mobile-pairing 404.
- **buzz-agent-control** (systemd, /opt/buzz-agent-control): receives
NIP-98-signed start requests at https://ac.ch4t.buzz/ac/start from the
owner, starts allowlisted `buzz-agent-*.service` systemd units on the VM.
Maps agent pubkey → unit via AGENT_UNITS_JSON (currently hermes →
buzz-agent-hermes.service). Polkit rule allows the opc user to manage only
`buzz-agent-*` units.
- **buzz-web** (client.ch4t.buzz): the browser client build; config.json
sets relayUrl + agentControlUrl.
## Key files
- /home/opc/buzz/ — upstream clone + our compose deployment (.env = secrets)
- /home/opc/buzz-agent-key.txt, buzz-hermes-key.txt, buzz-antigravity-key.txt,
buzz-openfang-key.txt, buzz-owner-key.txt — identity keys (600 perms)
- /home/opc/zai-home-base/buzz/ — our k8s manifests + onboarding doc (git)
- /home/opc/.buzz-inbox, .buzz-last-seen, .buzz-reacted,
.buzz-pending.json — listener state

View file

@ -0,0 +1,52 @@
---
apiVersion: v1
kind: Service
metadata:
name: buzz-agent-control
spec:
ports:
- port: 80
targetPort: 8095
---
apiVersion: v1
kind: Endpoints
metadata:
name: buzz-agent-control
subsets:
- addresses:
- ip: 10.0.0.215
ports:
- port: 8095
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: buzz-agent-control
spec:
rules:
- host: ac.ch4t.buzz
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: buzz-agent-control
port:
number: 80
tls:
- hosts:
- ac.ch4t.buzz
secretName: ac-ch4t-tls
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: ac-ch4t-buzz
spec:
secretName: ac-ch4t-tls
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
dnsNames:
- ac.ch4t.buzz

View file

@ -16,8 +16,8 @@ hermes secret from /home/opc/buzz-hermes-key.txt (already a relay + #general mem
extra: extra:
relay_url: https://ch4t.buzz relay_url: https://ch4t.buzz
channels: channels:
- 733ba424-73c7-4170-ac85-ba00d3a80f05 - (channel uuid — set when Dahjah creates the channel)
home_channel: 733ba424-73c7-4170-ac85-ba00d3a80f05 home_channel: (channel uuid — set when Dahjah creates the channel)
poll_interval: 4 poll_interval: 4
require_mention: false # trusted small circle; true = only @hermes require_mention: false # trusted small circle; true = only @hermes
allow_all_users: true # tiny private relay, everyone is known allow_all_users: true # tiny private relay, everyone is known
@ -39,7 +39,7 @@ chmod 600; relay role = member, #general channel member):
| openfang | 97c70de2… | /home/opc/buzz-openfang-key.txt | | openfang | 97c70de2… | /home/opc/buzz-openfang-key.txt |
| (owner) | 03dcf395… | /home/opc/buzz-owner-key.txt | | (owner) | 03dcf395… | /home/opc/buzz-owner-key.txt |
Channel: #general = 733ba424-73c7-4170-ac85-ba00d3a80f05 Channel: #general = (channel uuid — set when Dahjah creates the channel)
Relay URL (clients): wss://ch4t.buzz Relay URL (clients): wss://ch4t.buzz
Relay URL (buzz CLI / REST): https://ch4t.buzz Relay URL (buzz CLI / REST): https://ch4t.buzz
@ -58,9 +58,9 @@ Relay URL (buzz CLI / REST): https://ch4t.buzz
3. Smoke test: `buzz users get``[]` means authenticated. 3. Smoke test: `buzz users get``[]` means authenticated.
4. Talk: 4. Talk:
``` ```
buzz channels join --channel 733ba424-73c7-4170-ac85-ba00d3a80f05 buzz channels join --channel (channel uuid — set when Dahjah creates the channel)
buzz messages send --channel 733ba424-73c7-4170-ac85-ba00d3a80f05 --content "hello from hermes" buzz messages send --channel (channel uuid — set when Dahjah creates the channel) --content "hello from hermes"
buzz messages get --channel 733ba424-73c7-4170-ac85-ba00d3a80f05 --limit 20 buzz messages get --channel (channel uuid — set when Dahjah creates the channel) --limit 20
``` ```
5. Wire those commands into the harness as its messaging tool. Receive = poll. 5. Wire those commands into the harness as its messaging tool. Receive = poll.
@ -79,7 +79,7 @@ Note: it needs `#p` mention tags to trigger — address the agent as @name.
Point it at wss://ch4t.buzz with its private key. It must handle Point it at wss://ch4t.buzz with its private key. It must handle
NIP-42 auth (challenge → signed kind:22242) and post kind:9 notes with tag NIP-42 auth (challenge → signed kind:22242) and post kind:9 notes with tag
["h", "733ba424-73c7-4170-ac85-ba00d3a80f05"]. ["h", "(channel uuid — set when Dahjah creates the channel)"].
## Adding a brand-new agent later (operator commands, on djg-oracle-sl) ## Adding a brand-new agent later (operator commands, on djg-oracle-sl)
@ -88,5 +88,5 @@ PUB=<64-char hex pubkey>
sudo docker exec buzz-prod-relay-1 buzz-admin add-member --pubkey $PUB --role member sudo docker exec buzz-prod-relay-1 buzz-admin add-member --pubkey $PUB --role member
sudo /usr/local/bin/k3s kubectl ... # not needed sudo /usr/local/bin/k3s kubectl ... # not needed
export BUZZ_RELAY_URL=https://ch4t.buzz BUZZ_PRIVATE_KEY=<owner sec> export BUZZ_RELAY_URL=https://ch4t.buzz BUZZ_PRIVATE_KEY=<owner sec>
/home/opc/buzz/target/debug/buzz channels add-member --channel 733ba424-73c7-4170-ac85-ba00d3a80f05 --pubkey $PUB /home/opc/buzz/target/debug/buzz channels add-member --channel (channel uuid — set when Dahjah creates the channel) --pubkey $PUB
``` ```

View file

@ -13,14 +13,58 @@ import { execFileSync } from 'node:child_process'
const RELAY = 'wss://ch4t.buzz' const RELAY = 'wss://ch4t.buzz'
const ME = '4abe5fcdf9695be34bdfc8fe82297aaaf0cf65b5ca92b6c789f6f8783b5ee197' const ME = '4abe5fcdf9695be34bdfc8fe82297aaaf0cf65b5ca92b6c789f6f8783b5ee197'
const DM_CHANNEL = '4113fff6-f288-46af-927e-094b5a8386f2'
const INBOX = '/home/opc/.buzz-inbox' const INBOX = '/home/opc/.buzz-inbox'
const STATE = '/home/opc/.buzz-last-seen' const STATE = '/home/opc/.buzz-last-seen'
const MAX_LIFETIME = 120 * 1000 // reconnect every 2 min: replays since lastSeen (zombie-socket proof) const PENDING = '/home/opc/.buzz-pending.json'
const MAX_LIFETIME = 6 * 3600 * 1000 // periodic reconnect for hygiene
const RESUBSCRIBE_MS = 30 * 1000 // liveness probe: re-REQ pulls missed events + EOSE proves the socket
const DEAD_AFTER_MS = 60 * 1000 // no frames for this long -> socket assumed dead, exit for restart
const keyText = fs.readFileSync('/home/opc/buzz-agent-key.txt', 'utf8') const keyText = fs.readFileSync('/home/opc/buzz-agent-key.txt', 'utf8')
const SK = Uint8Array.from(keyText.match(/SECRET:\s*(\S+)/)[1].match(/.{2}/g).map((h) => parseInt(h, 16))) const SK = Uint8Array.from(keyText.match(/SECRET:\s*(\S+)/)[1].match(/.{2}/g).map((h) => parseInt(h, 16)))
const MY_PUBKEY = getPublicKey(SK) const MY_PUBKEY = getPublicKey(SK)
const RECEIPT_EMOJIS = ['👀', '💬']
function loadPending() {
try { return JSON.parse(fs.readFileSync(PENDING, 'utf8')) } catch { return {} }
}
function savePending(o) { fs.writeFileSync(PENDING, JSON.stringify(o)) }
// 👀 = seen, 💬 = working. Both removed when zai posts its reply.
function addReceipts(evt, ch) {
const p = loadPending()
p[ch] = p[ch] || []
if (p[ch].includes(evt.id)) return
for (const emoji of RECEIPT_EMOJIS) {
try {
execFileSync(BUZZ_CLI, ['reactions', 'add', '--event', evt.id, '--emoji', emoji], {
env: { ...process.env, BUZZ_RELAY_URL: 'https://ch4t.buzz', BUZZ_PRIVATE_KEY: keyText.match(/SECRET:\s*(\S+)/)[1] },
stdio: 'ignore',
timeout: 15000,
})
} catch (e) {
console.error('reaction add failed:', emoji, String(e).slice(0, 80))
}
}
p[ch].push(evt.id)
savePending(p)
}
function clearReceipts(ch) {
const p = loadPending()
for (const id of p[ch] || []) {
for (const emoji of RECEIPT_EMOJIS) {
try {
execFileSync(BUZZ_CLI, ['reactions', 'remove', '--event', id, '--emoji', emoji], {
env: { ...process.env, BUZZ_RELAY_URL: 'https://ch4t.buzz', BUZZ_PRIVATE_KEY: keyText.match(/SECRET:\s*(\S+)/)[1] },
stdio: 'ignore',
timeout: 15000,
})
} catch {}
}
}
delete p[ch]
savePending(p)
}
function lastSeen() { function lastSeen() {
try { return parseInt(fs.readFileSync(STATE, 'utf8').trim(), 10) } catch { return Math.floor(Date.now() / 1000) } try { return parseInt(fs.readFileSync(STATE, 'utf8').trim(), 10) } catch { return Math.floor(Date.now() / 1000) }
@ -32,6 +76,32 @@ function saveLastSeen(ts) {
const ws = new WebSocket(RELAY) const ws = new WebSocket(RELAY)
const seen = new Set() const seen = new Set()
const typingIntervals = new Map() // channel -> interval publishing kind 20002
const TYPING_PUBLISH_MS = 3000
const TYPING_MAX_MS = 120 * 1000 // hard stop: never type longer than this
function startTyping(ch) {
stopTyping(ch) // reset both the interval and the max-duration timer
const publish = () => {
try {
const ev = finalizeEvent(
{ kind: 20002, created_at: Math.floor(Date.now() / 1000), tags: [['h', ch]], content: '' },
SK
)
ws.send(JSON.stringify(['EVENT', ev]))
} catch {}
}
publish()
typingIntervals.set(ch, {
iv: setInterval(publish, TYPING_PUBLISH_MS),
max: setTimeout(() => stopTyping(ch), TYPING_MAX_MS),
})
console.error('typing indicator on:', ch.slice(0, 8))
}
function stopTyping(ch) {
const t = typingIntervals.get(ch)
if (t) { clearInterval(t.iv); clearTimeout(t.max); typingIntervals.delete(ch) }
}
let authEventId = null let authEventId = null
let subscribed = false let subscribed = false
@ -41,6 +111,10 @@ function subscribe() {
ws.send(JSON.stringify(['REQ', 'workspace', { kinds: [9], since: lastSeen() }])) ws.send(JSON.stringify(['REQ', 'workspace', { kinds: [9], since: lastSeen() }]))
console.error('subscribed to all kind-9 messages since', lastSeen()) console.error('subscribed to all kind-9 messages since', lastSeen())
} }
function resubscribe() {
// same subscription id: relay answers EOSE (liveness) and replays anything missed
ws.send(JSON.stringify(['REQ', 'workspace', { kinds: [9], since: lastSeen() }]))
}
function sendAuth(challenge) { function sendAuth(challenge) {
const ev = finalizeEvent( const ev = finalizeEvent(
@ -56,6 +130,7 @@ ws.onopen = () => {
console.error('ws open — waiting for auth challenge') console.error('ws open — waiting for auth challenge')
} }
ws.onmessage = (msg) => { ws.onmessage = (msg) => {
lastFrameAt = Date.now()
let data let data
try { data = JSON.parse(msg.data) } catch { return } try { data = JSON.parse(msg.data) } catch { return }
const [type, ...rest] = data const [type, ...rest] = data
@ -74,25 +149,39 @@ ws.onmessage = (msg) => {
if (type === 'NOTICE') { console.error('NOTICE:', rest[0]); return } if (type === 'NOTICE') { console.error('NOTICE:', rest[0]); return }
if (type === 'EVENT') { if (type === 'EVENT') {
const evt = rest[1] const evt = rest[1]
if (!evt || evt.pubkey === ME || seen.has(evt.id)) return if (!evt) return
if (evt.pubkey === ME) {
const chTag = (evt.tags.find((t) => t[0] === 'h') || [])[1]
if (chTag) {
stopTyping(chTag)
clearReceipts(chTag) // zai replied -> remove 👀/💬 from pending messages
}
return
}
if (seen.has(evt.id)) return
seen.add(evt.id) seen.add(evt.id)
saveLastSeen(evt.created_at + 1) saveLastSeen(evt.created_at + 1)
fs.appendFileSync(INBOX, JSON.stringify(evt) + '\n') fs.appendFileSync(INBOX, JSON.stringify(evt) + '\n')
console.error('message received from', evt.pubkey.slice(0, 10)) console.error('message received from', evt.pubkey.slice(0, 10))
// Instant DM receipt: the sender should never wonder if we're alive. const inCh = (evt.tags.find((t) => t[0] === 'h') || [])[1]
const chTag = (evt.tags.find((t) => t[0] === 'h') || [])[1] || '' if (inCh) {
if (chTag === DM_CHANNEL) { startTyping(inCh)
try { addReceipts(evt, inCh)
execFileSync(BUZZ_CLI, ['messages', 'send', '--channel', chTag, '--reply-to', evt.id, '--content', '✓ received — will respond in a moment'], { timeout: 15000, stdio: 'ignore' })
} catch {}
} }
process.exit(0)
} }
if (type === 'EOSE') console.error('EOSE — live') if (type === 'EOSE') console.error('EOSE — live')
} }
ws.onclose = () => { console.error('ws closed'); process.exit(2) } ws.onclose = () => { console.error('ws closed'); process.exit(2) }
ws.onerror = () => {} ws.onerror = () => {}
setInterval(resubscribe, RESUBSCRIBE_MS)
setInterval(() => {
if (Date.now() - lastFrameAt > DEAD_AFTER_MS) {
console.error('socket dead — no frames for 60s')
process.exit(2)
}
}, 15000)
let lastFrameAt = Date.now()
setTimeout(() => { console.error('lifetime elapsed'); process.exit(3) }, MAX_LIFETIME) setTimeout(() => { console.error('lifetime elapsed'); process.exit(3) }, MAX_LIFETIME)
const BUZZ_CLI = '/usr/local/bin/buzz' const BUZZ_CLI = '/usr/local/bin/buzz'

192
forgejo/k8s/forgejo.yaml Normal file
View file

@ -0,0 +1,192 @@
# ============================================================
# Forgejo — self-hosted git forge on k3s
# https://git.ch4t.buzz
# ============================================================
---
apiVersion: v1
kind: Namespace
metadata:
name: forgejo
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: forgejo-data
namespace: forgejo
spec:
accessModes: [ReadWriteOnce]
resources:
requests:
storage: 20Gi
storageClassName: local-path
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: forgejo
namespace: forgejo
labels:
app: forgejo
spec:
replicas: 1
selector:
matchLabels:
app: forgejo
strategy:
type: Recreate
template:
metadata:
labels:
app: forgejo
spec:
nodeSelector:
kubernetes.io/hostname: djg-oracle-sl
tolerations:
- key: node-role.kubernetes.io/master
operator: Exists
effect: NoSchedule
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
securityContext:
fsGroup: 1000
initContainers:
- name: init-dirs
image: busybox:latest
command: ["sh", "-c", "mkdir -p /data/gitea && chown -R 1000:1000 /data"]
volumeMounts:
- name: data
mountPath: /data
containers:
- name: forgejo
image: codeberg.org/forgejo/forgejo:9
ports:
- name: http
containerPort: 3000
- name: ssh
containerPort: 22
env:
- name: GITEA__server__DOMAIN
value: "git.ch4t.buzz"
- name: GITEA__server__ROOT_URL
value: "https://git.ch4t.buzz/"
- name: GITEA__server__HTTP_PORT
value: "3000"
- name: GITEA__server__SSH_DOMAIN
value: "git.ch4t.buzz"
- name: GITEA__server__SSH_PORT
value: "30022"
- name: GITEA__server__START_SSH_SERVER
value: "false"
- name: GITEA__server__DISABLE_SSH
value: "true"
- name: GITEA__database__DB_TYPE
value: "sqlite3"
- name: GITEA__database__PATH
value: "/data/gitea/gitea.db"
- name: GITEA__repository__ROOT
value: "/data/git/repositories"
- name: GITEA__log__MODE
value: "console"
- name: GITEA__log__LEVEL
value: "info"
- name: GITEA__actions__ENABLED
value: "true"
- name: GITEA__actions__DEFAULT_ACTIONS_URL
value: "https://code.forgejo.org"
volumeMounts:
- name: data
mountPath: /data
resources:
requests:
memory: 256Mi
cpu: 100m
limits:
memory: 1Gi
cpu: 1000m
readinessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 20
periodSeconds: 10
livenessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 60
periodSeconds: 30
volumes:
- name: data
persistentVolumeClaim:
claimName: forgejo-data
---
apiVersion: v1
kind: Service
metadata:
name: forgejo-http
namespace: forgejo
spec:
selector:
app: forgejo
ports:
- name: http
port: 3000
targetPort: http
---
apiVersion: v1
kind: Service
metadata:
name: forgejo-ssh
namespace: forgejo
spec:
type: NodePort
selector:
app: forgejo
ports:
- name: ssh
port: 22
targetPort: ssh
nodePort: 30022
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: forgejo
namespace: forgejo
spec:
rules:
- host: git.ch4t.buzz
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: forgejo-http
port:
number: 3000
tls:
- hosts:
- git.ch4t.buzz
secretName: forgejo-tls
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: forgejo
namespace: forgejo
spec:
secretName: forgejo-tls
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
dnsNames:
- git.ch4t.buzz