Status: accepted (phases 1–4) (2026-06-25) — shipped end-to-end, local +
cloud-ready: the spine (pkg/trigger + pkg/eventbus); board events
(board-mode promote); a direct-mode launcher over runview.Service;
run-completion chaining (run.finished/failed/cancelled);
scheduled (in-process Scheduler over schedule-kind subscriptions, the
local twin of cloudsched); git-forge events on the bus (the shared
webhook launch tail emits a SourceForge event, observational via the
launched_run_id marker so it can’t double-launch); and custom ingress
(POST /api/v1/triggers/emit injects a SourceCustom event onto the spine —
the first-class extensibility point for arbitrary external systems). The
studio Automations view (/triggers, gated on triggers_enabled) lists
and manages every subscription by repo/by bot with a create form per source.
Staged follow-ons: the forge cutover (spine becomes the forge launcher,
inline path retired behind a parity flag), forge-derived subscription
provisioning, and dispatcher EngineRunner convergence.
Iterion could already launch a bot run from several triggers, but each was a siloed implementation with its own config model, storage, and launch path:
pkg/cli/schedule.go → RunRun() directly.pkg/dispatcher/ polls native/github/forgejo
every 30s → Runner.Dispatch(DispatchSpec).pkg/webhooks/ + pkg/server/webhooks_*.go →
invocation_dispatch.go → runview.Launch.invocations: DSL — bundle.Invocation{Kind: forge|command|
schedule|board} — the closest existing unifying contract, but only the
webhook/forge path consumed it.The costs: three non-converged launch paths (RunRun, runview.Launch,
Runner.Dispatch); an iterion board event could only fire a bot via the
30s poll; no run-completion → run chaining; pkg/cloudsched existed but
never fired; and no first-class custom-integration trigger beyond the
single generic webhook.
Two composing layers, plus one canonical event channel.
Capability stays where it is. bundle.Invocation remains the
bot-authored “what surfaces can fire me” — no repo/tenant/cron knowledge
enters a manifest. InvocationKindBoard gains an optional board: block
(on / to_states / all_labels) with fail-fast parse validation.
A new binding layer — pkg/trigger. trigger.Subscription binds
(event filter) → (bot launch into a target): one row per
(tenant, repo, bot, invocation-kind). The pure trigger.Matcher is the
union of every legacy family’s allowlists (sources/kinds/actions/repos/
authors/labels/subject-states), so each old config maps on without losing
fidelity. SubscriptionStore mirrors forge.RepoIntegrationStore
(in-memory for local single-host, Mongo for cloud), with ListByRepo /
ListByBot powering the “by repo / by bot” surfaces and ListCandidates
the evaluator hot path.
A new internal event bus — pkg/eventbus. Bus{Publish, Subscribe}
with two impls: InProcBus (local, lossy fan-out à la
runview.EventBroker) and NATSBus (cloud, a new ITERION_EVENTS
stream / iterion.events.* subject — deliberately separate from the
iterion.queue.runs work queue, because events are at-least-once fan-out
notifications, not exactly-once locked work). The same trigger.Evaluator
consumes the bus identically in both modes; only the Bus impl is injected
differently.
The packages avoid an import cycle by layering: eventbus imports trigger
(for Event/Matcher); trigger imports neither eventbus nor runview.
The evaluator’s Handle(ctx, Event) error matches eventbus.Handler, so the
wiring layer (pkg/server) connects them; the board source publishes through a
tiny local trigger.Publisher interface that Bus satisfies structurally.
A native-board card created/moved/labeled fires a bot without the 30s wait:
pkg/trigger/board_source.go subscribes
to native.Store.Subscribe (the writer-agnostic events.jsonl tailer) — no new
store append point — normalizes native.Event → trigger.Event{Source:
board, Kind: card.*}, and publishes to the bus. Lifecycle copied from
pkg/server/watch_coordinator.go.NativeBoardEffect.Promote stamps the matched card’s Bot/BotArgs (and,
if the subscription names one, an eligible state). The dispatcher’s existing
tracker.Claim stays the sole launch authority — so the event fast-path
and the poll safety-net are structurally unable to double-launch, and the
promote is idempotent (a card already pinned to the bot is left untouched, so
an event storm converges). After a real change it nudges the dispatcher
(Manager.Refresh()) so the card dispatches now instead of at the next poll.Wired in pkg/server (StartTriggerCoordinator, reached by both iterion
studio and iterion dispatch); discovery-driven via
cli.buildLocalTriggerStore (a bot opts into event-driven promotion purely by
adding a board: block — zero engine/CLI edit). REST CRUD at
/api/v1/triggers, gated by server_info.triggers_enabled.
runview.Launch from pkg/trigger directly? It would cycle once
run-completion emits events back into the bus from runview. The Launcher
interface lives in trigger; its runview-wrapping impl lives in the wiring
layer.Each later source = “a source adapter that publishes a trigger.Event + an
effect choice (promote-card vs direct launch)”:
serviceLauncher
(pkg/server/trigger_launcher.go) wraps runview.Service.Launch, resolving
the bot via botregistry.ResolveBotPath. STAGED: routing RunRun and the
dispatcher’s EngineRunner.Dispatch execution step through it too, which
needs four per-launch LaunchSpec additions (WorkDir, ExtraObservers,
DailyCap, SourceRef) to preserve the dispatcher’s workspace/stall/cap/
retry invariants.runview.Service.emitRunCompletion
publishes run.finished/run.failed/run.cancelled onto the shared bus
(wired via SetEventPublisher(coord.Bus())); a direct-mode subscription
matching Source: run fires the downstream bot. The Actor carries the
upstream bot id so a chain can key on “after feature-dev finishes”.trigger.Scheduler ticks schedule-kind subscriptions
on their Cron and fires them via the launcher, scoped to the local tenant
“” (no-op in cloud, where cloudsched’s CAS ticker stays authoritative for
real tenants). FromScheduleInvocation seeds a schedule subscription from a
bot’s suggested_cron. STAGED: folding cloudsched.ScheduledBot into
Subscription{Invocation: schedule} so cloud + local share one table.insertAndLaunchWebhook) emits a SourceForge event with the
launched_run_id marker, so forge is a unified, observable source and the
evaluator never re-launches it. STAGED (the cutover): when a forge
subscription matches, skip the inline launch and let the spine launch
(stop setting the marker); parity-gate via the webhooks.Delivery audit,
then retire the inline path. The per-event payload["vars"] carrier the
evaluator now merges is the vehicle for forge’s dynamic launch vars.POST /api/v1/triggers/emit (requireAuth) forces
Source: custom (cannot spoof a board/forge event) and publishes onto the
bus; matching custom subscriptions fire asynchronously. STAGED: a cloud
signed-token variant alongside the local auth gate.New: pkg/trigger/{event,subscription,store,memstore,mongostore,evaluator,
launcher,board_source}.go, pkg/eventbus/{bus,inproc}.go,
pkg/server/{trigger_coordinator,triggers_routes}.go, pkg/cli/trigger.go.
Modified: pkg/bundle/manifest.go (InvocationBoard), pkg/queue/nats/nats.go
(ITERION_EVENTS), pkg/dispatcher/manager.go (Refresh nudger),
pkg/server/{server,server_info}.go, pkg/cli/{studio,dispatch}.go.