Skip to content

feat(automation): webhook destinations, excluded terminals, and the editor follow-ups - #80

Merged
rockyway merged 36 commits into
developfrom
feature/automation-webhooks-exclusions
Sep 7, 2026
Merged

feat(automation): webhook destinations, excluded terminals, and the editor follow-ups#80
rockyway merged 36 commits into
developfrom
feature/automation-webhooks-exclusions

Conversation

@rockyway

@rockyway rockyway commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Implements plan 034 — a webhook destination, per-rule excluded terminals, and four templates — against spec 016.

What this adds

A — Webhook destination. A rule that passes Compare it can post to Discord, Slack, Teams or a custom endpoint. action becomes optional, so "tell me when the unattended build fails, and type nothing" is now expressible. Both destinations dispatch concurrently from one crossing; neither waits on the other, and partial failure is normal — a 403 from Discord marks that destination failed and leaves the terminal's Sent row alone.

B — Excluded terminals. A rule can watch allTerminals except the ones an exclusion criterion names. Exclusions live in their own table (automation_targets is keyed (rule_id, terminal_id) with no source column, so it could not carry them). The editor's preview and the engine derive matching − excluded = watching from the same function.

C — Four templates. nightly (a schedule with no pattern at all), settle (delay-then-retry), builddiscord (webhook-only, no action key), backoff (both destinations off one verdict). Ten templates total.

Schema

schema_version_for now takes &AutomationRule rather than &AutomationGraph — exclusions are rule columns, so as written it structurally could not see them, and an old-graph-plus-exclusions rule would have stamped 1 and let an older build run the action against exactly the terminals the user excluded. SUPPORTED_SCHEMA_VERSION is 3, stamped when a rule uses a webhook, omits its action, or carries an exclusion, and non-sticky in both directions.

This stops an older build executing a row it would misread. It cannot retrofit an older decoder, and it cannot stop an older build's target-edit path rewriting the row and dropping unknown fields — a hazard automation_store.rs already documents.

The webhook URL is a secret

Persisted and sent over IPC in the clear by design (spec D3/A3a); it must never reach display, logs, errors or exports. WebhookStep has a hand-written Debug that renders url: "<redacted>", and WebhookError is structurally leak-proof — it carries a transport class or a status code, never a reqwest::Error, whose Display can embed the URL.

Review found a real leak during implementation: a malformed stored graph put the endpoint inside a serde error that reached the skipped-row reason and the activity log.

Review

Three external review rounds (two at milestones, one over the whole branch), plus a fix round. Findings fixed:

  • Blocker: validation accepted a present but blank terminal action whenever a webhook existed, while dispatch keys off the action's mere presence — so the rule saved and then pressed a bare Enter into a live terminal. Fixed at both validator surfaces plus a shared-fixture case. (Dispatch is unchanged and correct: an empty message with submit: true is a deliberate bare submit used by the HTTP probe path.)
  • Major: the dry run never emitted a webhook row, so spec A3b's redaction requirement for that surface passed vacuously.
  • Payload escaping was pinned for Discord only; Slack and Teams wrapper escaping is now bound by the same shared fixture.
  • a_v1_rule_still_round_trips_byte_for_byte round-tripped a CondStep, not a rule.

One finding rejected, deliberately

The final review called the dry run's masked URL a blocker, on the reading that the URL must never reach a dry run at all. Spec 016 §A3b's own table says the opposite: "Test / dry-run pane … A dry run shows the provider and the body, and a masked URL." The mask drops path, query and userinfo — everything credential-bearing for these providers — and keeps scheme+host so the user can tell which endpoint their rule would hit. Flagging it here so a human reviewer can overrule if they read the spec differently.

Follow-ups raised against the running build

Everything above is plan 034. The commits after it are Tam's own reports from a dev instance, each fixed at the class rather than the instance.

Behaviour

  • Delete a step from the canvas. Delete key on a focused card, plus a right-click menu. Removal drops the card and the graph field; the three input steps travel as a group. Fixing it also exposed never_runs_problem reading a webhook as a TRIGGER — a webhook rule with nothing to start it reported no problem at all.
  • Token chips that actually substitute. the context is over $0 posted the literal $0. The sender was right; the editor was wrong twice. Added the first tests that assert the posted bytes.
  • Insert captured values on by default, for new rules and templates only. Required narrowing action.tokenWithoutParse to fire on a TOKEN rather than on the flag — otherwise every schedule rule, which has no parse step by construction, would have opened blocked by a switch nobody touched.
  • monitor.skipTypedLine — an opt-in that drops the logical line the cursor sits on, so a command still being typed cannot fire a rule. Only that line, never "and everything below": the reported use is an agentic CLI whose status line sits under its input box, which is the text such a rule is usually watching for. Soft wrap is followed in both directions. Off writes no key; on stamps schema 3.

Editor chrome

  • A resizable (300–640px), collapsible settings panel that re-opens when a card is selected; collapsed is a rail, not a zero-width column, because the panel holds the only editor for the selected step.
  • One global scrollbar rule. Ten stylesheets had each grown their own copy, which is why a portalled dropdown drew a bare Win32 scrollbar inside a dark modal — a descendant selector cannot follow a createPortal.
  • The dropdown list no longer takes its trigger's exact width, which is the one width guaranteed to clip: an option longer than the closed control is why the list is being opened.
  • A readable danger row (a fill red was being used as text, 3.3:1), the collapse button moved clear of the header and of the scrollbar it shares an edge with, and two baseline-alignment bugs fixed as one — inline-flex boxes of different heights aligned on a baseline they do not share.
  • Worked examples behind an ⓘ on What this pattern finds, as a real panel rather than title: a native tooltip cannot hold a heading, a list and a code run, and is reachable by neither keyboard nor touch.

Method. Every new guard in these commits was mutation-checked — 24 mutants, all killed. Two earned their keep: one exposed a weak oracle in a test of my own (a soft wrap split the word the assertion looked for, so !contains(...) passed with the defect present), and one appeared to survive but had never actually applied.

Not verified by eye by me. Tam ran the GUI pass from a dev instance on this worktree across these rounds and signed off; I have not driven the app myself.

Gates

cargo test --manifest-path src-tauri/Cargo.toml     968 passed
bun run test                                        4189 passed, 236 suites
./node_modules/.bin/tsc --noEmit -p tsconfig.json   clean

Design and plan documents live in termflow-fabric (docs/design/016, docs/plan/034), not in this public repo.

The name claimed each template has its OWN pattern, comparison and message. That was true of the
original six and became false when backoff arrived: backoff is deliberately ratelimit plus a Teams
webhook (spec C4's fan-out), so it shares that template's pattern and message. Comparisons were
never checked for distinctness at all, and cannot be — every text rule's comparison is the same
{ text, null, null } triple.

Keep the counts exact so a second collision still fails, and name the permitted duplicate so
length - 1 cannot be satisfied by some other pair colliding instead.
The webhook glyph had no colour rule at all. The five original kinds each get one three-selector
group covering the palette icon, the canvas node icon and the inspector head; the sixth kind was
added without one, so its glyph inherited the body colour and rendered near-black on the dark rail.
A teal sits in the widest gap left between action's green and monitor's blue.

Pinned as a table over STEP_ORDER rather than as one case for webhook: the defect is that the colour
table and the step list could disagree in silence, so a seventh kind must fail the test too.

Also remove the 'Test run & activity' tab. The header's Test button already opens the drawer -- both
runDryRun paths set it -- so the tab was a second route to a pane that was already one click away.
Its now-orphaned style goes with it.
The editor is a 95vw x 95vh dialog, so there is no margin for a toast to sit in: at the default
top-right it lands on the editor's own Enabled / Test / Save / Delete. A toast is transient, so the
second of two clicks aimed at one goes to the control underneath -- and one of those deletes a rule.

Move the stack to the bottom-left while an editor is mounted, the one corner with nothing to hit:
the palette rail ends in the This rule summary, which is text. Overrides all four offsets and is
declared after the canvas modifier, since the editor can be opened FROM Canvas Mode and both
modifiers can then be on the same element.

Keyed on the MOUNT registry, not the open-request host: the Settings page opens its editor without
going through that host, so a signal taken from there would have fixed one of the editor's two homes
and left the other exactly as it was.
The marks sat visibly above the title. The rows keep align-items: flex-start on purpose -- most
carry a smaller second line, and centring the whole block drops the control halfway down a two-line
paragraph -- but flex-start alone pins the mark's top to the text's top, which is not the title's
centre. A fixed 2px/1px margin-top was standing in for the offset and was about 2.5px short.

Derive it instead: (first line box - mark height) / 2, with the line box named once on the shared
row rule. In rem, because .au-cmark sets its own font-size and an em basis would resolve against
0.75rem for the checkbox and 0.9rem for the radio and silently disagree.
The $0 / $1 / $2 / $$ row sat flush against the message box, reading as part of its border. Give it
the 6px `.au-fhelp` already uses for the same relationship -- a row explaining the control above it.

Qualified with `.au-finput + `: three panels render this row, and only ActionPanel and WebhookPanel
put it under an input. In ParsePanel it is the field's first child, straight after `.au-flabel`,
which already has a 6px bottom margin -- so an unqualified rule would have fixed the two that touch
and doubled the gap on the third.
Saving a Command contains rule with a matching terminal already open showed 'Nothing to watch. No
open terminal matches ...' with an Error pill, then corrected itself. Navigating away and back was
not what fixed it -- two seconds passing was.

state_payload reported every live rule from the moment it went live, taking its pairs from
watched_for, which returns an empty set both for a rule the targeting loop has resolved to nothing
AND for one it has not reached yet. The row treats those as opposites: absent is 'waiting' (the
engine has not reported this rule), empty is the Nothing to watch error. A save reloads the live
set, so every rule-mode rule claimed nothing matched it until the next targeting pass, up to
TARGETING_TICK_MS later -- a window that opens on the two occasions a user is most likely to be
looking, their own save and app start.

The distinction already exists in the data: the targeting pass calls set_watched for every live
rule, empty result included, so an absent key means 'not resolved yet' and only that. Keep it by
asking has_resolved instead of flattening it, and report the rule as absent until it is answered.

Pinned as a table over all three states. The middle row is what makes the first safe: a rule that
genuinely matches no terminal must still be reported, or the error becomes unreachable.
The interval list opened downward near the bottom of the window and was clipped mid-row, with no
scrollbar and no way to reach the options below the cut.

TermFlow runs decorations:false, so WebView2 composites the native option popup inside the window
surface rather than handing it to the OS. Chromium still picks the side from screen geometry, so it
opens down into space the compositor then clips. Nothing in CSS reaches a native popup, so the list
has to be ours.

AuSelect portals its list to body and positions it fixed -- the inspector column is overflow-y:auto
and .au-modal is overflow:hidden, either of which would clip an absolutely positioned child -- and
flips it above the trigger when there is not room below, capping its height to the space it has.

Applied to all eight of the editor's selects, not only the one reported. The editor is a 95vh dialog
whose inspector scrolls, so ANY of its selects can be the bottom-most control; the test pane's
terminal picker sits in the bottom drawer and was the most exposed of the lot. Carried the two
capabilities the native markup was using: optgroup headings for the Text/Number comparison list, and
disabled rows for terminals that have closed, refused by pointer and by keyboard alike.

Placement is pinned as a PAIR. 'Sets bottom when it flips' is satisfied by a component that always
flips, which would move the same bug to the top of the window; both mutants were run and both fail.
A card could be added and never taken off. The wait step is the one most
often unwanted, and there was no gesture, no undo and no field that
cleared it -- so a rule that grew one was stuck with it.

Two gestures, one removal. Delete on a focused card, and a right-click
menu; both reach the editor's `removeStep`, which is to a removal what
`addStep` already is to an add. Pulling a destination's wire chip still
removes it, and now goes through the same reducer helper rather than its
own copy of "re-derive the wires, clear the selection if it pointed at
what just went".

A removal takes the card off the canvas AND deletes the step's graph
field. `graphAsWritten` omits only the input group and the two
destinations, so a wait merely hidden would have gone on delaying every
send with nothing on screen to say so.

The three reading steps come off together. `eval::InputSteps::of`
answers `None` for a strict subset, so removing one at a time would
write a rule that looks complete and silently reads nothing;
`removalGroup` is the single place that is decided, and the menu item
and the toast both name what will actually go.

Fixes a hole that made the gesture reachable: `never_runs_problem`
treated a webhook as a TRIGGER and exempted any graph carrying one. A
webhook rule with nothing to start it reported no problem at all --
saveable, enabled, silent forever -- reachable by API or import since
the step existed, and by deleting the reading steps once a card could be
deleted at all. Both mirrors and the shared fixture.

Cards are now tab stops, which is what aims the key; until now only
their ports were reachable by keyboard.
`.canvas-menu .context-menu-item.danger` took `--danger-color`
(#e81123). That is a FILL red -- a button's background with white text
over it -- and as text on the #2d2d30 menu it is 3.3:1, a dim maroon
rather than a warning. Reported on the automation canvas's Delete row;
Canvas Mode's node menu Close row shares the one declaration and had it
too.

#ffaea8 is the red the automation editor already uses for its
blocking-problem text, so this borrows an answer instead of inventing an
eleventh one. 7.8:1 on the menu, 5.5:1 on the #094771 hover -- the
danger rule keeps its colour on hover while every other row turns white,
so the row is read against two grounds and only one was ever checked.

The test computes the CONTRAST RATIO rather than asserting the hex. "It
is not #e81123 any more" passes for every other unreadable red.
Reported: a webhook body reading `the context is over $0` posted the
literal `$0` to Discord.

The sender was right. `run_webhook` substitutes when `webhook.substitute`
is set, and nothing covered it -- every webhook test asserted only that a
request ARRIVED, which a rule posting its template verbatim does just as
reliably. `capturing_webhook_endpoint` keeps the request bytes, and two
tests now pin both directions: substitution on posts the resolved body
and no raw token, off posts the body exactly as typed.

The editor was wrong twice.

The chips and the toggle disagreed. Clicking `$0` inserted a reference
into a body sent verbatim, so the click produced nothing but literal
text -- an affordance for something the rule would not do. Inserting a
token now sets `substitute` in the same patch, so the two fields cannot
land apart. Both destinations: the terminal action carries the same chips
over the same toggle.

A TYPED token said nothing at all, which is the half the chips cannot
reach. Each panel now names the tokens that will go out literally while
substitution is off. Filtered by what the pattern can actually supply, via
`resolvableTokens`: `$5` in "the build cost $5 of compute" IS a token by
the grammar, so a note built on `tokensUsed` alone fires on prose, and a
note that fires on prose is one users learn to look past.
New rules and templates only. A SAVED rule carries its own value and
keeps it, so nothing already built changes what it types or posts.

Flipping the default meant the flag stopped being a statement of intent,
and two places were reading it as one:

`action.tokenWithoutParse` fired for a flag-on message whatever it
contained, on the ground that "the toggle itself claims the message
inserts a capture". A schedule rule has no parse step by construction, so
every one of them would have opened BLOCKED by a switch nobody touched.
The TOKEN is what claims a capture now; a message naming none substitutes
to itself.

Both panels previewed the same way -- "Nothing would be sent: there is no
pattern yet to capture values from" over a send that is perfectly fine.
Caught by the Nightly deploy template's own render test, not by the
validator change.
Ten stylesheets had each grown their own near-identical copy, so a
scrollable surface was styled if and only if someone remembered it. The
one that got noticed shows the shape of the failure: the automation
editor styles `.au-editor ::-webkit-scrollbar`, and its own dropdown
portals to `body` precisely so a clipping ancestor cannot cut it off --
which puts it outside that selector and drew a bare Win32 scrollbar in
the middle of a dark modal. A descendant selector cannot follow a portal.

Unscoped, in index.css. Breadth is safe in a way `*` never is: the only
thing these pseudo-elements can match is a scrollbar.

The thumb's inset is a transparent border clipped to the padding box, not
a background colour. The editor's copy hard-codes its own modal's ground
(#17181c), and lifting that verbatim would have drawn a dark halo around
the thumb on every surface with a different background -- the one thing
that stopped it being liftable as it stood.

The per-component copies are now redundant rather than wrong, and are
left alone: any of them may be deliberately different.
340px clipped the comparison row -- a source select, an operator select
and a value field on one line -- so the operator ellipsed to `con...`,
which is the whole of what it has to say. It starts at 400 now, drags
between 300 and 640 by its left edge, and collapses.

Collapsed is a RAIL, never a zero-width column. The panel holds the only
editor for whichever step is selected, so a hidden panel plus a card
click is a user selecting something they can neither see nor change.
Two ways back, one deliberate and one automatic: the rail keeps its
reopen button, and selecting a card opens the dock. Clicking the
background deselects and deliberately does NOT open it -- announcing that
nothing is selected is the opposite of what that click asked for.

The dock wraps AuInspector rather than growing it two responsibilities,
which also leaves its signature and its six direct test call sites alone.
It is the grid child now and carries its own inline width, so one number
drives the column instead of a track and a variable disagreeing.

Width and collapsed state persist in localStorage, every access guarded:
the accessor itself throws where site data is blocked, and an editor that
will not open because a preference could not be read is the worse
failure. The stored width is clamped on the way IN as well as out.
Reported: a rule fired on a command that was still being typed, before
Enter. The screen genuinely contains that text and nothing in the bytes
separates an echoed keystroke from output, so the rule is given a way to
say the line the cursor is parked on is not output yet.

Only the cursor's OWN logical line goes, never "and everything below":
the reported use is an agentic CLI whose status line sits under its input
box, and that status line is usually the text the rule is watching for.
Soft wrap is followed in both directions - press Home on a long command
and the cursor sits on its first row, so a backward-only walk would leave
the rest of the command behind for the pattern to match.

Opt-in, and it has to be: a full-screen TUI parks its cursor wherever it
likes, and a rule that silently gained this would lose matches with no
way to see why. Off writes no key, so a rule that never ticks the box
keeps a blob an older build reads byte for byte; ticking it stamps the
current schema version, because an older build decodes such a rule
perfectly and then does the one thing the box was ticked to stop.

The flag rides beside ReadDepth rather than inside it - depth_for answers
how far back to read, this answers whether one line of what it finds
counts - and is applied in `evaluate`, the one place a ScreenSource
becomes a reader.

Ten mutants killed. One of them found a weak oracle in this commit's own
test: the wrap fell mid-word, so asserting `!contains("cluster")` passed
with the continuation row still there. Both wrap tests now assert the
whole output.
…ples behind an info icon

Four things reported against the running build.

**The collapse button covered `Nothing selected`.** The gutter that keeps
text clear of it was keyed to `.au-ihead` - the header a SELECTED step
renders - so the empty state, a different element, drew underneath it. It
is now keyed to the panel's first child, whatever that is, so the next
state cannot be the same bug a third time. The test derives the gutter
from the button's own left + width + border rather than restating 32.

**A flyout option opened as `does not e...`.** The list carried
`width: trigger.width`, which is the one width guaranteed to be too
small: an option longer than the closed control is exactly why the list
is being opened. It now shrink-wraps between a `minWidth` of the trigger
and a `maxWidth` of the room to the window edge.

**`Re-arm now` touched the state pill.** JSX siblings with no literal
text between them produce no whitespace node, so the two inline-flex
boxes were drawn flush. Adjacent-sibling margin, not a gap on the group:
when a rule has fired, a block line comes between them and the button
starts its own line, where a left margin would only indent it.

**Examples behind an info icon on "What this pattern finds".** A custom
panel rather than `title`: a native tooltip cannot hold a heading, a list
and a code run, appears on a delay, leaves on a timeout, and is reachable
by neither keyboard nor touch. Click to open, Escape to close - swallowed
so it does not also discard the unsaved rule behind it - portalled past
the two clipping ancestors, and set at a reading size the test asserts
against the label it hangs off rather than as a bare number.

Nine mutants killed across the four.
**The collapse button to the top-right**, where the header has room. It
also stops sharing the left edge with the resize grip, which is what the
z-index between them was settling. Inset 12px rather than 4: `.au-inspect`
scrolls and its scrollbar runs down that same edge, so at 4 the button
would cover the top of it - the one thing that space is not empty of. The
collapsed rail overrides the inset back, having no scrollbar and no room
for it. The gutter follows the button to the right, still derived in the
test from the button's own rule rather than restated, and a new test
asserts the two name the SAME edge: moving one without the other is not a
visibly broken stylesheet, just a wrong one.

**Re-arm now rode high against its pill, and the info icon sat low
against its label** - one cause, twice. Both pairs are inline-flex boxes
of different heights and font sizes, aligned on a baseline they do not
share, and no `vertical-align` length is right for both sides of either
pair. The RIGHT NOW group becomes a flex row (which also supplies the gap
that used to be an adjacent-sibling margin, and wraps, since the panel
resizes to 300px) and `.au-flabel` centres its items. Neither answer now
depends on font metrics.

The `Fired ...` line moves below the pill and button rather than between
them - it is detail about the state, and it was what separated the pill
from its own action.

Five mutants killed.
@rockyway rockyway changed the title feat(automation): webhook destinations and excluded terminals feat(automation): webhook destinations, excluded terminals, and the editor follow-ups Sep 7, 2026
@rockyway
rockyway merged commit 84e89dd into develop Sep 7, 2026
5 checks passed
@rockyway
rockyway deleted the feature/automation-webhooks-exclusions branch September 7, 2026 17:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants