Skip to content

feat: generic EventSource/EventTrigger webhooks + cron, replacing ai-triage/ai-review hardcoding (ADR 0048) - #305

Merged
imaustink merged 9 commits into
mainfrom
feat/event-triggers
Oct 14, 2026
Merged

imaustink merged 9 commits into
mainfrom
feat/event-triggers

Conversation

@imaustink

@imaustink imaustink commented Oct 14, 2026 •

Copy link
Copy Markdown
Owner

Summary

Webhook behaviour is now defined entirely by two CRDs, replacing IntegrationRoute and the hard-coded ai-triage/ai-review logic. ai-triage and ai-review are now just example EventTrigger instances. ADR 0048 supersedes 0024 and amends 0025. The contract is in docs/event-triggers.md.

  • EventSource: where events come from.
    • A webhook source is served at POST /webhooks/<name>. The gateway verifies the raw bytes with hmacSha256, slackV0 or bearer before parsing anything.
    • A cron source has a schedule. core-controller turns it into an owned CronJob.
    • An optional sender block resolves and checks who sent the event (for GitHub: team or collaborator membership, and bots are dropped).
    • Every source produces the same normalized envelope.
  • EventTrigger: what an event does.
    • match.cel decides whether it fires, and params extracts values with CEL.
    • The target is exactly one of the orchestrator, a skill, an agent or a tool.
    • Also: promptTemplate, a sessionKey template, priority, and readOnly.
    • Replies are declared on the trigger: reply.github (ack, session page, result, removeLabel) and reply.http (an optionally signed callback).
  • Both engines: LangGraph uses cel-js and Temporal uses cel-go. A shared fixture suite (packages/event-triggers/fixtures/cel-parity.json) checks they agree.
  • Read-only:
    • It is now a structured field: readOnly on AgentRun and ToolRun, passed to agents as AGENT_READ_ONLY and TOOL_READ_ONLY.
    • It stays on for later turns in the same session, and those turns get no write credentials.
    • The SWE-ENFORCED-MODE: review-only prompt sentinel is deleted.
  • Cron authentication: each cron source gets its own ServiceAccount. The tick presents a short-lived token minted for the gateway's audience, and the gateway checks it with TokenReview.
    • There is no shared secret, so the chart renders the same under Helm and Argo.
    • One source's identity can never tick another source.
  • Charts and examples:
    • The four route templates and the ai-triage/ai-review code paths are deleted.
    • Thirteen example CRs replace them as bare manifests in examples/event-triggers/, outside every chart: 5 sources (GitHub, Slack, CI, generic HMAC, nightly cron) and 8 triggers covering all four target types. Apply with kubectl apply -n <ns> -k examples/event-triggers.
    • Production installs its GitHub source and four ai-triage/ai-review triggers through the agent-controller chart's extraManifests. They are generated from the examples, render the same 5 objects the chart did, and keep the GitHub webhook URL unchanged.
    • e2e applies the examples through a kustomize overlay (e2e/manifests/event-triggers) that only swaps in stubs, e2e Secrets and fake-sink replies.
    • The umbrella chart version is now 0.7.0.

Behaviour changes

  • A trigger that targets a tool now reports failed when the tool fails, in both engines.
  • /invoke with an event returns 200 ignored when no trigger matches (no turn starts), 422 when params fail, and 202 with {trigger: {name, sessionId, reply}} on a match.
  • The orchestrator retries its NATS connection, so it no longer crash-loops when a deploy restarts NATS and Qdrant alongside it.

Deploy notes

  • release.yml deletes the retired integrationroutes CRD.
  • The gateway gains a ClusterRole whose only permission is create on tokenreviews. TokenReview is cluster-scoped, so a namespaced Role can't grant it.

Test plan

  • Unit tests: all TS workspaces, packages/event-triggers (122 tests including the CEL parity fixtures), the Temporal engine's go test ./..., core-controller envtest, and lint.
  • E2E on minikube with the Temporal engine: all event-trigger specs pass.
    • Specs: github, readonly, filtering, verification, reply-http, cron, invoke and status.
    • These cover every verification scheme, every target type, sticky read-only, signed HTTP replies, cron (manual tick, scheduler, suspend, delete) and ServiceAccount tick authentication.
  • E2E on minikube with the LangGraph engine: all event-trigger specs pass.
  • After the switch to ServiceAccount tick auth, the cron and verification specs were rerun on Temporal (17 of 17 pass). The full suites were not rerun.

Known failures that predate this branch

  • mcp-broker-contract fails: its expectations went stale when main's e4436ff added hash suffixes to MCP tool ids.
  • Resilience "recovers the reply" fails in Temporal mode: if the orchestrator pod handling a turn dies mid-turn, that turn's /invoke record stays pending forever.
  • LangGraph engine gaps: it has no knowledge-base dispatch, and a chat device-flow link resume parks. The affected specs now run only on Temporal, with the reason recorded next to each gate.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Q4FyzpR8xiataUPf68J4qs

imaustink and others added 9 commits October 13, 2026 15:12
…0048)

Adds the two generic event CRDs, readOnly/params on AgentRun and readOnly
on ToolRun, the event-trigger contract (docs/event-triggers.md) and the
cross-engine CEL parity fixtures. Removes the IntegrationRoute API and
controller. Reconcilers are scaffolds; behavior lands in follow-up commits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4FyzpR8xiataUPf68J4qs
…0048)

- core-controller: EventSource reconciler (webhook secret/CEL readiness,
  cron -> owned CronJob ticking the gateway), EventTrigger reconciler (CEL
  + ref resolution), AGENT_READ_ONLY / AGENT_PARAMS / TOOL_READ_ONLY env.
- @controller-agent/event-triggers: shared TS envelope, CEL (cel-js),
  params, templates and trigger selection, with cross-engine parity
  fixtures.
- agent-orchestrator: EventTrigger registry, /invoke ignored/422/202
  contract, forced skill/agent/tool targets, sticky per-session readOnly,
  write credentials withheld on read-only turns, Temporal forwarding.
- temporal engine: cel-go trigger matching for direct callers, forced tool
  turns, sticky readOnly in the conversation workflow, readOnly/params on
  every AgentRun/ToolRun.
- integration-gateway: POST /webhooks/:source for any EventSource
  (hmacSha256 / slackV0 / bearer), sender verification, cron ticks, and
  declarative github/http replies; label/triage/review code removed.
- SWE agents: read-only from AGENT_READ_ONLY; the SWE-ENFORCED-MODE goal
  sentinel is gone. github tool refuses writes under TOOL_READ_ONLY.
- charts: IntegrationRoute templates replaced by example EventSources and
  EventTriggers (production reproduces ai-triage/ai-review), RBAC and env.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4FyzpR8xiataUPf68J4qs
…h engines

Signers for every verification scheme, a fake-sink reply recorder, per-test
EventSource/EventTrigger fixtures, and specs for GitHub triage/review,
read-only enforcement (incl. sticky sessions), filtering, verification,
http replies (orchestrator/skill/tool targets), cron ticks, programmatic
/invoke, trigger precedence, and status conditions. rbac specs cover the
new kinds; up.sh deploys either engine via E2E_ENGINE.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4FyzpR8xiataUPf68J4qs
…lout

skaffold stamps a fresh run-id on every pod template, so each e2e deploy
rolls NATS and Qdrant with the orchestrator. Retry the NATS connects like
Redis already is, and let skaffold's status check tolerate the transient
exit until its deadline instead of failing the run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4FyzpR8xiataUPf68J4qs
…llow-up specs

A toolRef trigger is a function call, so the Temporal engine now fails the
turn when the forced tool fails instead of answering "I couldn't complete
that" as a success -- reply.http callers see status failed, matching
LangGraph. The e2e generic-hmac specs reset fake-sink first so neither reads
the other's reply, and the session-page follow-up spec waits for the turn
to finish and asserts nothing it launched is writable (Temporal's read gate
refuses a follow-up that names no verified repository).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4FyzpR8xiataUPf68J4qs
…tub reply

The shipped slack-mention-chat EventTrigger can now POST its outcome via
eventTriggers.slackMentionChat.reply.{url,secretRef} like the ci and
generic-hmac examples (chart 0.3.1). bridged-agent-workflow.e2e.ts no longer
mistakes the trigger's ack comment for the agent's reply.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4FyzpR8xiataUPf68J4qs
Running the suite on LangGraph for the first time surfaced two gaps that
predate event triggers: the TS dispatcher has no knowledge-base dispatch
kind, and its device-flow link resume parks on a failed long-poll. Both are
now skipIf(!isTemporal()) with the reason inline instead of failing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4FyzpR8xiataUPf68J4qs
Drop the chart-generated shared tick token. Helm `lookup` returns nothing
under `helm template`/Argo CD, so the token was regenerated on every render.

core-controller now gives each cron EventSource an owned ServiceAccount
(eventsource-<name>, no automount, no RoleBinding). The tick CronJob runs as
it with only a projected token for audience integration-gateway.events. The
gateway TokenReviews the bearer and accepts a tick for source X only from
system:serviceaccount:<ns>:eventsource-X, so one source can never tick
another. There is nothing to generate, store or rotate.

The gateway gets a ClusterRole with only `create` on tokenreviews.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4FyzpR8xiataUPf68J4qs
The 13 example EventSources/EventTriggers are now bare manifests in
examples/event-triggers/ (plus a kustomization and a README). They are not
part of any chart, and community-components drops its eventSources/
eventTriggers templates and values.

- Production installs its GitHub source and four ai-triage/ai-review
  triggers through the agent-controller chart's extraManifests, generated
  from the examples. It renders exactly the same 5 objects as before.
- e2e applies the examples through a kustomize overlay
  (e2e/manifests/event-triggers) that only swaps in stubs, e2e Secrets and
  fake-sink replies. It renders exactly the same 13 objects as values-e2e.yaml did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4FyzpR8xiataUPf68J4qs
@imaustink
imaustink merged commit 433d85a into main Oct 14, 2026
8 checks passed
@imaustink
imaustink deleted the feat/event-triggers branch October 14, 2026 14:55
imaustink added a commit that referenced this pull request Oct 14, 2026
…ching cost

- Read-only is the session's mode, set by its latest trigger turn (writable
  included); non-trigger turns inherit it and can only restrict. One review no
  longer leaves an issue's triage read-only for good. Both engines.
- LangGraph read-only turns can't write: no forwarding into a writable agent
  run (launch mode recorded on the session), MCP tools refused, and tools that
  act on GitHub as the caller refused instead of falling back to the Tool
  template's own GITHUB_TOKEN. Matches Temporal.
- Temporal /invoke refuses dispatch fields in the body (400); an event always
  goes through trigger matching. The orchestrator's matched-trigger hop travels
  as a signed, session- and prompt-bound x-orchestrator-dispatch-assertion.
- Temporal /invoke with no triggers configured is 200 ignored, not 503.
- integration-gateway skips the GitHub sender lookup when no trigger for the
  source could match before sender resolution (read-only EventTrigger watch).
- A trigger is Ready only when its condition's observedGeneration is current.
- Cron event ids carry a random suffix.
- EventTrigger/EventSource controllers skip no-op status writes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4FyzpR8xiataUPf68J4qs
imaustink added a commit that referenced this pull request Oct 14, 2026
TokenReview needs a ClusterRole, and the CI deployer cannot write
ClusterRoles: the #305 deploy failed on exactly that, and the rollback
that followed took production's webhooks down. The gateway now verifies a
tick's projected ServiceAccount token itself, against the API server's
issuer discovery + /openid/v1/jwks (readable by every ServiceAccount by
default): signature, issuer, audience, exp, and a lifetime of at most an
hour. An unknown key id refetches the keys once, rate limited. The chart
no longer ships a ClusterRole for the gateway.

Trade-off: a ServiceAccount deleted after its token was minted is not
noticed; the tick token's 10-minute life bounds that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4FyzpR8xiataUPf68J4qs
imaustink added a commit that referenced this pull request Oct 14, 2026
…igest

The #305 deploy failed before Helm wrote a revision, so `helm rollback 0`
went one release too far (0.6.4 -> 0.6.3). That restarted every pod, and
with `:latest` + Always they pulled the new code and ran it on the old
config: the gateway 404'd every webhook, the orchestrator crash-looped.

- rollback-deploy.sh rolls back to the last revision with status
  `deployed`, and does nothing when the failed upgrade never wrote one.
- scripts/pin-image-digests.sh resolves the three Deployments' `:latest`
  to its digest (GHCR, anonymous, curl + jq) and the deploy passes it as
  `tag: latest@sha256:...`, so the release records the image and a
  rollback restores it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4FyzpR8xiataUPf68J4qs
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.

1 participant