Skip to content

Commit dbb5acd

Browse files
fix: register workflow triggers on apply (#162)
1 parent 1e87631 commit dbb5acd

6 files changed

Lines changed: 543 additions & 64 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@mantle/engine": patch
3+
---
4+
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/server/email_trigger.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ const (
2222
emailReconnectBaseDelay = 5 * time.Second
2323
emailReconnectMaxDelay = 5 * time.Minute
2424
defaultMaxEmailConns = 5
25+
// emailReloadInterval is how often the poller reconciles its running
26+
// pollers against workflow_triggers, so email triggers registered by a
27+
// later `mantle apply` (a separate process) or a GitOps sync start
28+
// without requiring a server restart. Cron and webhook triggers are read
29+
// from the DB on every tick/request, so they need no equivalent.
30+
emailReloadInterval = 30 * time.Second
2531
)
2632

2733
// EmailTriggerPoller starts one goroutine per email trigger and maintains a
@@ -58,6 +64,25 @@ func (e *EmailTriggerPoller) Start(ctx context.Context) error {
5864
e.startPoller(ctx, t)
5965
}
6066

67+
// Periodically reconcile against the DB so triggers added or removed
68+
// after startup take effect without a restart.
69+
e.wg.Add(1)
70+
go func() {
71+
defer e.wg.Done()
72+
ticker := time.NewTicker(emailReloadInterval)
73+
defer ticker.Stop()
74+
for {
75+
select {
76+
case <-ctx.Done():
77+
return
78+
case <-ticker.C:
79+
if err := e.Reload(ctx); err != nil {
80+
e.server.Logger.Error("email poller: periodic reload failed", "error", err)
81+
}
82+
}
83+
}
84+
}()
85+
6186
e.server.Logger.Info("email trigger poller started", "triggers", len(triggers))
6287
return nil
6388
}

packages/engine/internal/server/trigger.go

Lines changed: 4 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ package server
33
import (
44
"context"
55
"database/sql"
6-
"fmt"
76

87
"github.com/dvflw/mantle/internal/auth"
98
)
@@ -27,53 +26,10 @@ type TriggerRecord struct {
2726
PollInterval string
2827
}
2928

30-
// SyncTriggers replaces all triggers for a workflow with the given set.
31-
// Called by `mantle apply` when a workflow definition changes.
32-
func SyncTriggers(ctx context.Context, db *sql.DB, workflowName string, version int, triggers []TriggerInput) error {
33-
teamID := auth.TeamIDFromContext(ctx)
34-
35-
tx, err := db.BeginTx(ctx, nil)
36-
if err != nil {
37-
return fmt.Errorf("starting transaction: %w", err)
38-
}
39-
defer tx.Rollback()
40-
41-
// Remove existing triggers for this workflow, scoped to team.
42-
_, err = tx.ExecContext(ctx,
43-
`DELETE FROM workflow_triggers WHERE workflow_name = $1 AND team_id = $2`, workflowName, teamID)
44-
if err != nil {
45-
return fmt.Errorf("deleting old triggers: %w", err)
46-
}
47-
48-
// Insert new triggers.
49-
for _, t := range triggers {
50-
_, err = tx.ExecContext(ctx,
51-
`INSERT INTO workflow_triggers
52-
(workflow_name, workflow_version, type, schedule, path, secret, team_id, mailbox, folder, filter, poll_interval)
53-
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
54-
workflowName, version, t.Type, t.Schedule, t.Path, t.Secret, teamID,
55-
nullableString(t.Mailbox), nullableString(t.Folder), nullableString(t.Filter), nullableString(t.PollInterval))
56-
if err != nil {
57-
return fmt.Errorf("inserting trigger: %w", err)
58-
}
59-
}
60-
61-
return tx.Commit()
62-
}
63-
64-
// TriggerInput is the data needed to register a trigger.
65-
type TriggerInput struct {
66-
Type string
67-
Schedule string
68-
Path string
69-
Secret string
70-
71-
// Email trigger fields (used only when Type == "email").
72-
Mailbox string
73-
Folder string
74-
Filter string
75-
PollInterval string
76-
}
29+
// Trigger registration lives in the workflow package (workflow.Save), which
30+
// reconciles workflow_triggers whenever a definition is applied. The functions
31+
// below are the read side consumed by the cron scheduler, webhook handler, and
32+
// email poller.
7733

7834
// ListCronTriggers returns all enabled cron triggers, including team_id for proper scoping.
7935
func ListCronTriggers(ctx context.Context, db *sql.DB) ([]TriggerRecord, error) {
@@ -148,12 +104,3 @@ func scanEmailTriggers(rows *sql.Rows) ([]TriggerRecord, error) {
148104
}
149105
return triggers, rows.Err()
150106
}
151-
152-
// nullableString converts an empty string to nil so that nullable TEXT columns
153-
// receive NULL rather than an empty string.
154-
func nullableString(s string) interface{} {
155-
if s == "" {
156-
return nil
157-
}
158-
return s
159-
}

packages/engine/internal/workflow/store.go

Lines changed: 75 additions & 7 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

@@ -44,14 +51,32 @@ func Save(ctx context.Context, database *sql.DB, result *ParseResult, rawContent
4451
return 0, fmt.Errorf("marshaling workflow: %w", err)
4552
}
4653

47-
_, err = database.ExecContext(ctx,
54+
tx, err := database.BeginTx(ctx, nil)
55+
if err != nil {
56+
return 0, fmt.Errorf("starting transaction: %w", err)
57+
}
58+
defer tx.Rollback() //nolint:errcheck
59+
60+
_, err = tx.ExecContext(ctx,
4861
`INSERT INTO workflow_definitions (name, version, content, content_hash, team_id) VALUES ($1, $2, $3, $4, $5)`,
4962
name, newVersion, content, hash, teamID,
5063
)
5164
if err != nil {
5265
return 0, fmt.Errorf("inserting workflow definition: %w", err)
5366
}
5467

68+
// Register the workflow's declared triggers so the cron/webhook/email
69+
// schedulers actually fire it. Done in the same transaction as the
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 {
73+
return 0, err
74+
}
75+
76+
if err := tx.Commit(); err != nil {
77+
return 0, fmt.Errorf("committing workflow save: %w", err)
78+
}
79+
5580
return newVersion, nil
5681
}
5782

@@ -194,7 +219,13 @@ func GetVersion(ctx context.Context, database *sql.DB, name string, version int)
194219
// speculatively during a GitOps prune pass).
195220
func Disable(ctx context.Context, database *sql.DB, name string) error {
196221
teamID := auth.TeamIDFromContext(ctx)
197-
_, err := database.ExecContext(ctx,
222+
tx, err := database.BeginTx(ctx, nil)
223+
if err != nil {
224+
return fmt.Errorf("disabling workflow %q: %w", name, err)
225+
}
226+
defer tx.Rollback() //nolint:errcheck
227+
228+
if _, err := tx.ExecContext(ctx,
198229
`UPDATE workflow_definitions
199230
SET disabled_at = NOW()
200231
WHERE id = (
@@ -203,8 +234,23 @@ func Disable(ctx context.Context, database *sql.DB, name string) error {
203234
ORDER BY version DESC LIMIT 1
204235
)`,
205236
name, teamID,
206-
)
207-
if err != nil {
237+
); err != nil {
238+
return fmt.Errorf("disabling workflow %q: %w", name, err)
239+
}
240+
241+
// Disable the workflow's triggers too — the schedulers key off
242+
// workflow_triggers.enabled, not the definition's disabled_at, so a
243+
// pruned workflow would otherwise keep firing. Guarded on enabled = true
244+
// so repeated GitOps passes don't churn updated_at.
245+
if _, err := tx.ExecContext(ctx,
246+
`UPDATE workflow_triggers SET enabled = false, updated_at = NOW()
247+
WHERE workflow_name = $1 AND team_id = $2 AND enabled = true`,
248+
name, teamID,
249+
); err != nil {
250+
return fmt.Errorf("disabling triggers for %q: %w", name, err)
251+
}
252+
253+
if err := tx.Commit(); err != nil {
208254
return fmt.Errorf("disabling workflow %q: %w", name, err)
209255
}
210256
return nil
@@ -214,7 +260,13 @@ func Disable(ctx context.Context, database *sql.DB, name string) error {
214260
// workflow. Safe to call when already enabled — becomes a no-op.
215261
func Reenable(ctx context.Context, database *sql.DB, name string) error {
216262
teamID := auth.TeamIDFromContext(ctx)
217-
_, err := database.ExecContext(ctx,
263+
tx, err := database.BeginTx(ctx, nil)
264+
if err != nil {
265+
return fmt.Errorf("reenabling workflow %q: %w", name, err)
266+
}
267+
defer tx.Rollback() //nolint:errcheck
268+
269+
if _, err := tx.ExecContext(ctx,
218270
`UPDATE workflow_definitions
219271
SET disabled_at = NULL
220272
WHERE id = (
@@ -223,8 +275,24 @@ func Reenable(ctx context.Context, database *sql.DB, name string) error {
223275
ORDER BY version DESC LIMIT 1
224276
)`,
225277
name, teamID,
226-
)
227-
if err != nil {
278+
); err != nil {
279+
return fmt.Errorf("reenabling workflow %q: %w", name, err)
280+
}
281+
282+
// Restore the workflow's triggers. This covers the GitOps case where a
283+
// workflow was pruned (triggers disabled) and later reappears with
284+
// byte-identical content: Save is a no-op so it won't re-register them,
285+
// but Reenable must. Guarded on enabled = false to stay a true no-op on
286+
// the common already-enabled path.
287+
if _, err := tx.ExecContext(ctx,
288+
`UPDATE workflow_triggers SET enabled = true, updated_at = NOW()
289+
WHERE workflow_name = $1 AND team_id = $2 AND enabled = false`,
290+
name, teamID,
291+
); err != nil {
292+
return fmt.Errorf("reenabling triggers for %q: %w", name, err)
293+
}
294+
295+
if err := tx.Commit(); err != nil {
228296
return fmt.Errorf("reenabling workflow %q: %w", name, err)
229297
}
230298
return nil
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package workflow
2+
3+
import (
4+
"context"
5+
"database/sql"
6+
"fmt"
7+
)
8+
9+
// syncTriggersTx reconciles the workflow_triggers rows for a workflow within an
10+
// existing transaction. It removes the workflow's current triggers (scoped to
11+
// the team) and inserts the declared set, all enabled. This is what makes
12+
// cron/webhook/email triggers declared in a workflow's YAML actually fire:
13+
// the cron scheduler, webhook handler, and email poller all read from
14+
// workflow_triggers, and without this reconciliation that table stays empty.
15+
//
16+
// It runs inside Save's transaction so a new definition version and its
17+
// triggers are committed atomically — a workflow is never left registered at
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 {
22+
// A new version supersedes the previous one, so replace whatever was
23+
// registered before rather than trying to diff. Scoped to team so we
24+
// never touch another tenant's rows.
25+
if _, err := tx.ExecContext(ctx,
26+
`DELETE FROM workflow_triggers WHERE workflow_name = $1 AND team_id = $2`,
27+
name, teamID); err != nil {
28+
return fmt.Errorf("clearing triggers for %q: %w", name, err)
29+
}
30+
31+
for _, t := range triggers {
32+
if _, err := tx.ExecContext(ctx,
33+
`INSERT INTO workflow_triggers
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)`,
36+
name, version, t.Type,
37+
nullableString(t.Schedule), nullableString(t.Path), nullableString(t.Secret), teamID,
38+
nullableString(t.Mailbox), nullableString(t.Folder), nullableString(t.Filter), nullableString(t.PollInterval),
39+
enabled,
40+
); err != nil {
41+
return fmt.Errorf("registering %s trigger for %q: %w", t.Type, name, err)
42+
}
43+
}
44+
45+
return nil
46+
}
47+
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+
98+
// nullableString maps an empty string to NULL so that nullable TEXT columns
99+
// receive NULL rather than an empty string. Reads COALESCE these back to
100+
// their defaults.
101+
func nullableString(s string) any {
102+
if s == "" {
103+
return nil
104+
}
105+
return s
106+
}

0 commit comments

Comments
 (0)