Skip to content

Commit b5da9b0

Browse files
committed
fix: backfill triggers on unchanged re-apply; test coverage
Addresses PR review feedback on the trigger-registration fix. - workflow.Save reconciles triggers on the unchanged-content path too, so workflows applied before this fix (definition present, workflow_triggers empty) register their triggers on a plain re-apply instead of requiring a byte-level YAML change. Reconciliation only runs when the stored rows have actually drifted from the latest version's declared triggers, so steady-state GitOps re-applies don't churn rows (which matters for the email poller, whose IMAP connections key off trigger IDs). - Backfilled rows inherit the workflow's enabled/disabled state, so a disabled workflow doesn't start firing on re-apply. - Tests: assert webhook path/secret persistence, add backfill regression (including no-churn on repeat apply) and disabled-state preservation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WnKgCRfMvFcZAkoVbr8k79
1 parent 0395d4c commit b5da9b0

4 files changed

Lines changed: 190 additions & 7 deletions

File tree

.changeset/fix-trigger-registration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
"@mantle/engine": patch
33
---
44

5-
Register workflow triggers on apply. Cron, webhook, and email triggers declared in a workflow's YAML were parsed and validated but never written to `workflow_triggers`, so they never fired. `workflow.Save` now reconciles the table in the same transaction as the definition insert (covering both `mantle apply` and GitOps sync), pruned/disabled workflows have their triggers disabled so they stop firing, and the email poller periodically reloads so new email triggers activate without a server restart.
5+
Register workflow triggers on apply. Cron, webhook, and email triggers declared in a workflow's YAML were parsed and validated but never written to `workflow_triggers`, so they never fired. `workflow.Save` now reconciles the table in the same transaction as the definition insert (covering both `mantle apply` and GitOps sync). Re-applying an unchanged workflow backfills its triggers if the table has drifted (so upgrading and re-applying activates triggers for workflows created before this fix, without churning rows in steady state). Pruned/disabled workflows have their triggers disabled so they stop firing, and the email poller periodically reloads so new email triggers activate without a server restart.

packages/engine/internal/workflow/store.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,13 @@ func Save(ctx context.Context, database *sql.DB, result *ParseResult, rawContent
3030
return 0, fmt.Errorf("checking latest hash: %w", err)
3131
}
3232
if latestHash == hash {
33+
// Content unchanged, so no new version — but still backfill triggers
34+
// if the table drifted from the definition (e.g. a workflow applied
35+
// before trigger registration existed). Otherwise upgrading and
36+
// re-applying would leave triggers inert until a byte-level change.
37+
if err := reconcileTriggersIfDrifted(ctx, database, teamID, name, result.Workflow.Triggers); err != nil {
38+
return 0, err
39+
}
3340
return 0, nil
3441
}
3542

@@ -60,8 +67,9 @@ func Save(ctx context.Context, database *sql.DB, result *ParseResult, rawContent
6067

6168
// Register the workflow's declared triggers so the cron/webhook/email
6269
// schedulers actually fire it. Done in the same transaction as the
63-
// definition insert so version and triggers commit atomically.
64-
if err := syncTriggersTx(ctx, tx, teamID, name, newVersion, result.Workflow.Triggers); err != nil {
70+
// definition insert so version and triggers commit atomically. A new
71+
// version is always active, so its triggers are enabled.
72+
if err := syncTriggersTx(ctx, tx, teamID, name, newVersion, result.Workflow.Triggers, true); err != nil {
6573
return 0, err
6674
}
6775

packages/engine/internal/workflow/triggers.go

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@ import (
1515
//
1616
// It runs inside Save's transaction so a new definition version and its
1717
// triggers are committed atomically — a workflow is never left registered at
18-
// one version with triggers pointing at another.
19-
func syncTriggersTx(ctx context.Context, tx *sql.Tx, teamID, name string, version int, triggers []Trigger) error {
18+
// one version with triggers pointing at another. enabled sets the rows'
19+
// enabled flag: true for an active workflow, false when backfilling a
20+
// currently-disabled one so it does not start firing.
21+
func syncTriggersTx(ctx context.Context, tx *sql.Tx, teamID, name string, version int, triggers []Trigger, enabled bool) error {
2022
// A new version supersedes the previous one, so replace whatever was
2123
// registered before rather than trying to diff. Scoped to team so we
2224
// never touch another tenant's rows.
@@ -29,11 +31,12 @@ func syncTriggersTx(ctx context.Context, tx *sql.Tx, teamID, name string, versio
2931
for _, t := range triggers {
3032
if _, err := tx.ExecContext(ctx,
3133
`INSERT INTO workflow_triggers
32-
(workflow_name, workflow_version, type, schedule, path, secret, team_id, mailbox, folder, filter, poll_interval)
33-
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
34+
(workflow_name, workflow_version, type, schedule, path, secret, team_id, mailbox, folder, filter, poll_interval, enabled)
35+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
3436
name, version, t.Type,
3537
nullableString(t.Schedule), nullableString(t.Path), nullableString(t.Secret), teamID,
3638
nullableString(t.Mailbox), nullableString(t.Folder), nullableString(t.Filter), nullableString(t.PollInterval),
39+
enabled,
3740
); err != nil {
3841
return fmt.Errorf("registering %s trigger for %q: %w", t.Type, name, err)
3942
}
@@ -42,6 +45,56 @@ func syncTriggersTx(ctx context.Context, tx *sql.Tx, teamID, name string, versio
4245
return nil
4346
}
4447

48+
// reconcileTriggersIfDrifted backfills a workflow's triggers on the
49+
// unchanged-content apply path. Save early-returns when the content hash is
50+
// unchanged, so without this a workflow applied before trigger registration
51+
// existed (definition present, workflow_triggers empty) would never register
52+
// its triggers unless the user made a byte-level YAML change. It reconciles
53+
// only when the stored rows are actually out of sync with the latest version's
54+
// declared triggers, so steady-state GitOps re-applies stay churn-free (which
55+
// matters for the email poller, whose IMAP connections key off trigger IDs).
56+
// The backfilled rows inherit the workflow's current enabled/disabled state.
57+
func reconcileTriggersIfDrifted(ctx context.Context, database *sql.DB, teamID, name string, desired []Trigger) error {
58+
// Latest version and whether it is currently active (not disabled).
59+
var version int
60+
var active bool
61+
err := database.QueryRowContext(ctx,
62+
`SELECT version, disabled_at IS NULL FROM workflow_definitions
63+
WHERE name = $1 AND team_id = $2 ORDER BY version DESC LIMIT 1`,
64+
name, teamID).Scan(&version, &active)
65+
if err == sql.ErrNoRows {
66+
return nil // no definition to reconcile against
67+
}
68+
if err != nil {
69+
return fmt.Errorf("loading latest version for %q: %w", name, err)
70+
}
71+
72+
// Already in sync when the row count matches and every row points at the
73+
// latest version. Covers both the empty-table (pre-fix) case and rows
74+
// left at a stale version.
75+
var total, atVersion int
76+
if err := database.QueryRowContext(ctx,
77+
`SELECT COUNT(*), COUNT(*) FILTER (WHERE workflow_version = $3)
78+
FROM workflow_triggers WHERE workflow_name = $1 AND team_id = $2`,
79+
name, teamID, version).Scan(&total, &atVersion); err != nil {
80+
return fmt.Errorf("checking trigger drift for %q: %w", name, err)
81+
}
82+
if total == len(desired) && atVersion == len(desired) {
83+
return nil
84+
}
85+
86+
tx, err := database.BeginTx(ctx, nil)
87+
if err != nil {
88+
return fmt.Errorf("starting transaction: %w", err)
89+
}
90+
defer tx.Rollback() //nolint:errcheck
91+
92+
if err := syncTriggersTx(ctx, tx, teamID, name, version, desired, active); err != nil {
93+
return err
94+
}
95+
return tx.Commit()
96+
}
97+
4598
// nullableString maps an empty string to NULL so that nullable TEXT columns
4699
// receive NULL rather than an empty string. Reads COALESCE these back to
47100
// their defaults.

packages/engine/internal/workflow/triggers_test.go

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,128 @@ func TestSave_RegistersTriggers(t *testing.T) {
9797
t.Errorf("email fields = (%q,%q,%q,%q), want (inbox-cred,INBOX,unseen,30s)",
9898
mailbox, folder, filter, poll)
9999
}
100+
101+
// The webhook trigger row must carry its path and secret.
102+
var path, secret string
103+
if err := database.QueryRowContext(ctx,
104+
`SELECT COALESCE(path,''), COALESCE(secret,'') FROM workflow_triggers
105+
WHERE workflow_name = 'triggered-wf' AND type = 'webhook'`,
106+
).Scan(&path, &secret); err != nil {
107+
t.Fatalf("querying webhook trigger: %v", err)
108+
}
109+
if path != "/incoming" || secret != "shh" {
110+
t.Errorf("webhook fields = (%q,%q), want (/incoming,shh)", path, secret)
111+
}
112+
}
113+
114+
// triggerIDs returns the sorted IDs of a workflow's trigger rows.
115+
func triggerIDs(t *testing.T, database *sql.DB, name string) []string {
116+
t.Helper()
117+
rows, err := database.QueryContext(context.Background(),
118+
`SELECT id::text FROM workflow_triggers WHERE workflow_name = $1 ORDER BY id`, name)
119+
if err != nil {
120+
t.Fatalf("querying trigger ids: %v", err)
121+
}
122+
defer rows.Close()
123+
var ids []string
124+
for rows.Next() {
125+
var id string
126+
if err := rows.Scan(&id); err != nil {
127+
t.Fatalf("scanning id: %v", err)
128+
}
129+
ids = append(ids, id)
130+
}
131+
return ids
132+
}
133+
134+
func equalStrings(a, b []string) bool {
135+
if len(a) != len(b) {
136+
return false
137+
}
138+
for i := range a {
139+
if a[i] != b[i] {
140+
return false
141+
}
142+
}
143+
return true
144+
}
145+
146+
// TestSave_BackfillsTriggersOnUnchangedApply is the regression test for the
147+
// upgrade path: a workflow whose definition already exists but has no
148+
// workflow_triggers rows (applied before trigger registration existed) must
149+
// get its triggers backfilled when identical content is re-applied — without
150+
// churning rows on subsequent no-op applies.
151+
func TestSave_BackfillsTriggersOnUnchangedApply(t *testing.T) {
152+
database := setupTestDB(t)
153+
ctx := context.Background()
154+
155+
result, _ := ParseBytes(triggeredWorkflowYAML)
156+
if _, err := Save(ctx, database, result, triggeredWorkflowYAML); err != nil {
157+
t.Fatalf("initial Save: %v", err)
158+
}
159+
160+
// Simulate a pre-fix workflow: definition present, trigger rows absent.
161+
if _, err := database.ExecContext(ctx,
162+
`DELETE FROM workflow_triggers WHERE workflow_name = 'triggered-wf'`); err != nil {
163+
t.Fatalf("clearing triggers: %v", err)
164+
}
165+
if got := countTriggers(t, database, "triggered-wf", false); got != 0 {
166+
t.Fatalf("precondition: triggers = %d, want 0", got)
167+
}
168+
169+
// Re-applying identical content must backfill the triggers (version stays 0).
170+
version, err := Save(ctx, database, result, triggeredWorkflowYAML)
171+
if err != nil {
172+
t.Fatalf("backfill Save: %v", err)
173+
}
174+
if version != 0 {
175+
t.Errorf("version = %d, want 0 (unchanged content)", version)
176+
}
177+
if got := countTriggers(t, database, "triggered-wf", true); got != 3 {
178+
t.Fatalf("backfilled enabled triggers = %d, want 3", got)
179+
}
180+
181+
// A further identical apply must be a true no-op: rows already in sync,
182+
// so their IDs must not churn (the email poller keys off trigger IDs).
183+
ids1 := triggerIDs(t, database, "triggered-wf")
184+
if _, err := Save(ctx, database, result, triggeredWorkflowYAML); err != nil {
185+
t.Fatalf("no-op Save: %v", err)
186+
}
187+
ids2 := triggerIDs(t, database, "triggered-wf")
188+
if !equalStrings(ids1, ids2) {
189+
t.Errorf("triggers churned on no-op apply: %v -> %v", ids1, ids2)
190+
}
191+
}
192+
193+
// TestSave_BackfillPreservesDisabledState verifies that backfilling triggers
194+
// for a currently-disabled workflow inserts them disabled, so a pruned
195+
// workflow does not start firing on a re-apply.
196+
func TestSave_BackfillPreservesDisabledState(t *testing.T) {
197+
database := setupTestDB(t)
198+
ctx := context.Background()
199+
200+
result, _ := ParseBytes(triggeredWorkflowYAML)
201+
if _, err := Save(ctx, database, result, triggeredWorkflowYAML); err != nil {
202+
t.Fatalf("initial Save: %v", err)
203+
}
204+
if err := Disable(ctx, database, "triggered-wf"); err != nil {
205+
t.Fatalf("Disable: %v", err)
206+
}
207+
// Drop the rows to force a backfill on the next apply.
208+
if _, err := database.ExecContext(ctx,
209+
`DELETE FROM workflow_triggers WHERE workflow_name = 'triggered-wf'`); err != nil {
210+
t.Fatalf("clearing triggers: %v", err)
211+
}
212+
213+
if _, err := Save(ctx, database, result, triggeredWorkflowYAML); err != nil {
214+
t.Fatalf("backfill Save: %v", err)
215+
}
216+
if got := countTriggers(t, database, "triggered-wf", false); got != 3 {
217+
t.Errorf("backfilled rows = %d, want 3", got)
218+
}
219+
if got := countTriggers(t, database, "triggered-wf", true); got != 0 {
220+
t.Errorf("enabled backfilled rows = %d, want 0 (workflow is disabled)", got)
221+
}
100222
}
101223

102224
// TestSave_ReplacesTriggersOnNewVersion verifies a new version supersedes the

0 commit comments

Comments
 (0)