Discord webhooks: a partial credentials file installs; the tick logs the missing keys; the watcher advances without posting

Main's ruling (6 October 2026, 20:1x UK): the box installs with the NUMBERS key alone so the 21:00 UK pulse comes from
it. install.sh accepts at least one key and names the missing ones; every tick's log line ends with "missing <keys>"; the
watcher without an incidents webhook logs its open or resolve and still advances its state, so the key landing later
does not flood the channel. Test added (31 passing).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-06 19:06:57 +00:00
parent 1e6876fa95
commit 93e59f7fa2
3 changed files with 36 additions and 3 deletions

View file

@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Install or refresh the Discord webhooks scheduler on igneum-build-1 from this Mac.
# IGNEUM_SECRET_ON_BOX_OK=1 infra/build-server/discord-hooks/install.sh
# IGNEUM_SECRET_ON_BOX_OK=1 infra/build-server/discord-hooks/install.sh (re-run whenever the credentials file changes)
# Copies tools/community/discord-hooks.mjs to /srv/discord-hooks/bin, the webhook file ~/.config/igneum/discord to
# /srv/discord-hooks/env (mode 600, owner build; never into the repository), the two units, and enables the timer.
# Needs root over ssh (root@<ip> with ~/.ssh/igneum_ed25519); the host ip comes from ~/.config/igneum/build-server (build@<ip>).
@ -16,9 +16,14 @@ HOST_LINE="$(head -1 "${IGNEUM_BUILD_HOST_FILE:-$HOME/.config/igneum/build-serve
IP="${HOST_LINE#*@}"; [ -n "$IP" ] || { echo "no build server in ~/.config/igneum/build-server" >&2; exit 1; }
[ "${IGNEUM_SECRET_ON_BOX_OK:-}" = "1" ] || { echo "refusing: the box holds no secret by rule; set IGNEUM_SECRET_ON_BOX_OK=1 once the coordinator has ruled the exception" >&2; exit 1; }
[ -f "$CRED" ] || { echo "no credentials file at $CRED (DISCORD_WEBHOOK_NUMBERS, _ANNOUNCEMENTS, _INCIDENTS)" >&2; exit 1; }
# a partial file is accepted (main's ruling, 6 October 2026, 20:1x UK): at least one key, the missing ones named here and
# by every tick's log line; re-run this script when the other webhooks are created to copy the fuller file
present=0; missing=""
for k in DISCORD_WEBHOOK_NUMBERS DISCORD_WEBHOOK_ANNOUNCEMENTS DISCORD_WEBHOOK_INCIDENTS; do
grep -q "^$k=" "$CRED" || { echo "credentials file lacks $k" >&2; exit 1; }
if grep -q "^$k=." "$CRED"; then present=$((present + 1)); else missing="$missing $k"; fi
done
[ "$present" -ge 1 ] || { echo "credentials file has none of the three webhook keys" >&2; exit 1; }
[ -z "$missing" ] || echo "note: missing${missing}; posts to those channels are logged, not sent, until the file is re-copied" >&2
MODE="$(stat -f %Lp "$CRED" 2>/dev/null || stat -c %a "$CRED")"
[ "$MODE" = "600" ] || { echo "credentials file must be mode 600 (is $MODE)" >&2; exit 1; }

View file

@ -560,6 +560,9 @@ export class Poster {
this.state.posts = this.state.posts || {}; this.state.pulses = this.state.pulses || []; this.state.incidents = this.state.incidents || {}; this.state.watch = this.state.watch || {};
}
already(key) { return !this.force && !!this.state.posts[key]; }
// the webhook keys the credentials file lacks (names only); empty in dry run
missingKeys() { return this.live ? Object.values(CHANNEL_KEY).filter(k => !this.creds[k]) : []; }
has(channel) { return !this.live || !!this.creds[CHANNEL_KEY[channel]]; }
// Posts one payload under an idempotency key; returns {posted, skipped, id}. Writes the dry-run JSON and preview in dry-run mode.
async post(channel, key, payload) {
const total = guardPayload(payload);
@ -710,6 +713,12 @@ export async function resolveIncident(poster, { id, at, cause, fix }) {
export async function runWatch(poster, data) {
const actions = watchPass(data, poster.state);
const results = [];
if (actions.length && !poster.has('incidents')) {
// the state still advances (no backlog flood when the key lands); the action is logged, not sent
for (const a of actions) poster.log(`watch: ${a.kind} ${a.id} not posted, no ${CHANNEL_KEY.incidents} in the credentials file`);
poster.save();
return [];
}
for (const a of actions) {
if (a.kind === 'open') results.push(await openIncident(poster, { id: a.id, at: a.at, what: a.what, affected: a.affected, doing: a.doing, auto: true }));
else results.push(await resolveIncident(poster, { id: a.id, at: a.at, cause: a.cause, fix: a.fix }));
@ -777,7 +786,8 @@ export async function main(argv = process.argv.slice(2)) {
}
const wdata = data && !data.fetchFailed ? data : await fetchForWatch();
const r = await runWatch(poster, wdata);
poster.log(`tick ${ukStamp(now)}: ${due.length} due, watch ${r.length} action(s)`);
const missing = poster.missingKeys();
poster.log(`tick ${ukStamp(now)}: ${due.length} due, watch ${r.length} action(s)${missing.length ? `, missing ${missing.join(', ')}` : ''}`);
return 0;
}
throw new Error(`unknown command ${cmd}`);

View file

@ -304,6 +304,24 @@ test('poster: live mode posts once, stores the message id, skips the rerun; the
assert.equal(calls.length, 1);
await assert.rejects(() => p.post('incidents', 'inc:x', payload), /no DISCORD_WEBHOOK_INCIDENTS/);
});
test('poster: a partial credentials file names its missing keys; the watcher advances its state and posts nothing without the incidents webhook', async () => {
const dir = tmpDir();
const cred = path.join(dir, 'discord'); fs.writeFileSync(cred, 'DISCORD_WEBHOOK_NUMBERS=https://discord.com/api/webhooks/1/secret\n', { mode: 0o600 });
const calls = [];
const fetchImpl = async (url, init) => { calls.push(url); return { ok: true, status: 200, headers: new Map(), json: async () => ({ id: '1' }) }; };
const logs = [];
const p = new Poster({ live: true, credFile: cred, stateFile: path.join(dir, 'state.json'), outDir: dir, log: m => logs.push(m), fetchImpl });
assert.deepEqual(p.missingKeys(), ['DISCORD_WEBHOOK_ANNOUNCEMENTS', 'DISCORD_WEBHOOK_INCIDENTS']);
assert.equal(p.has('numbers'), true); assert.equal(p.has('incidents'), false);
const { runWatch } = await import('./discord-hooks.mjs');
watchPass(data(), p.state, at(NOW, -60));
const lag = data(); lag.live.proving.median_proof_lag_s = 1000;
const r = await runWatch(p, lag);
assert.deepEqual(r, []);
assert.equal(calls.length, 0, 'nothing sent');
assert.match(logs.join('\n'), /open auto-proof_lag-\S+ not posted, no DISCORD_WEBHOOK_INCIDENTS/);
assert.ok(p.state.watch.proof_lag.open, 'the state still records the open, so the key landing later does not flood');
});
test('backoff: a 429 waits retry_after then doubles; success returns the id; six failures give up', async () => {
const sleeps = [];
let n = 0;