feat(automation): webhook destinations, excluded terminals, and the editor follow-ups - #80
Merged
Merged
Conversation
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.
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.
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.
actionbecomes 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'sSentrow alone.B — Excluded terminals. A rule can watch
allTerminalsexcept the ones an exclusion criterion names. Exclusions live in their own table (automation_targetsis keyed(rule_id, terminal_id)with nosourcecolumn, so it could not carry them). The editor's preview and the engine derivematching − excluded = watchingfrom the same function.C — Four templates.
nightly(a schedule with no pattern at all),settle(delay-then-retry),builddiscord(webhook-only, noactionkey),backoff(both destinations off one verdict). Ten templates total.Schema
schema_version_fornow takes&AutomationRulerather than&AutomationGraph— exclusions are rule columns, so as written it structurally could not see them, and an old-graph-plus-exclusions rule would have stamped1and let an older build run the action against exactly the terminals the user excluded.SUPPORTED_SCHEMA_VERSIONis 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.rsalready 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.
WebhookStephas a hand-writtenDebugthat rendersurl: "<redacted>", andWebhookErroris structurally leak-proof — it carries a transport class or a status code, never areqwest::Error, whoseDisplaycan 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:
submit: trueis a deliberate bare submit used by the HTTP probe path.)a_v1_rule_still_round_trips_byte_for_byteround-tripped aCondStep, 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
never_runs_problemreading a webhook as a TRIGGER — a webhook rule with nothing to start it reported no problem at all.the context is over $0posted the literal$0. The sender was right; the editor was wrong twice. Added the first tests that assert the posted bytes.action.tokenWithoutParseto 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
createPortal.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
Design and plan documents live in
termflow-fabric(docs/design/016,docs/plan/034), not in this public repo.