feat(worker): --webhook to POST task transitions to Slack/Discord/generic - #46
Merged
Conversation
…eric
Add a --webhook <url> flag that POSTs notable worker transitions (a task
done/blocked/needs-input/retrying, and the worker stopping/erroring) to a URL,
hung off the existing emit() transition point beside --emit-json. One
application/json payload serves three receivers with no per-target config:
text (Slack), content (Discord), and a structured { event, seq, data, worker,
ts } for a generic consumer.
Best-effort by construction: a POST never blocks or crashes the drain, a
per-request timeout plus an in-flight cap bound resource use against a
slow/dead endpoint, and flush() drains pending POSTs before every process exit
so the final event still lands. seq is monotonic so a receiver can reorder the
concurrently-delivered POSTs.
The URL is validated at startup (http/https only) and redacted in logs, since
a Slack/Discord URL carries its secret in the path. --webhook-secret HMAC-signs
the body (X-Bob-Signature) for a generic receiver to verify; the payload
transmits cwd, task titles, and question text, so point it only at a trusted
endpoint.
…it paths - seq advances only after JSON.stringify succeeds, so a serialization failure (e.g. a BigInt in the event data) no longer burns a sequence number and leaves a phantom gap a receiver would read as a dropped delivery. - validateWebhookUrl no longer echoes the URL in its error, and the parse error is redacted at the call site, so a rejected but secret-bearing URL can't leak to stderr — consistent with the redaction applied everywhere else. - flush() is a single self-bounded loop: it drains in-flight POSTs but its own wall clock (started at flush time) caps the total wait, so a slow endpoint or a steady trickle of new POSTs can't hold process exit open. Each round's timer is cleared rather than leaked, and there's no longer a dangling drain coroutine. - the stdin-end shutdown path flushes before exit; the second-Ctrl-C force-quit deliberately does not (a force path must not block on network I/O).
An unref'd deadline timer is abandoned when every in-flight POST is a pure
pending promise with no socket to hold the event loop: Node 22 tears the loop
down and flush() never resolves ("Promise resolution is still pending but the
event loop has already resolved"). The timer is cleared right after the race,
so it never outlives the call — unref was both unnecessary and the bug.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Add
--webhook <url>to the worker: POST notable task transitions — a task done / blocked / needs-input / retrying, and the worker itself stopping / erroring — to a URL, so an unattended drain can notify Slack, Discord, or a generic receiver without anyone watching the board.The webhook is a second sink hung off the worker's existing
emit()transition point, beside the--emit-jsonstdout stream. Oneapplication/jsonpayload serves all three receivers with no per-target config:text(Slack renders it),content(Discord renders it), and a structured{ event, seq, data, worker, ts }for a generic consumer.seqis monotonic, so concurrently-delivered POSTs can be reordered.Guarantees
Best-effort by construction: a POST never blocks or crashes the drain. A per-request timeout plus an in-flight cap bound resource use against a slow or dead endpoint, and
flush()— self-bounded by its own wall clock — drains pending POSTs before every process exit so the final event still lands.Security
The URL is validated at startup (http/https only, fail loud) and redacted in logs, since a Slack/Discord webhook URL carries its secret in the path. The payload transmits
cwd, task titles, and question text, so it's meant only for a trusted endpoint;--webhook-secret <s>HMAC-signs the body (X-Bob-Signature) for a generic receiver to verify.