Skip to content

Commit 0395d4c

Browse files
committed
fix: register workflow triggers on apply (#160)
Cron, webhook, and email triggers declared in a workflow's YAML were parsed and validated but never written to workflow_triggers, because the only function that populated that table (server.SyncTriggers) had no callers. The cron scheduler, webhook handler, and email poller all read from that table, so declared triggers never fired. - workflow.Save now reconciles workflow_triggers in the same transaction as the definition insert, so both `mantle apply` and GitOps sync register a workflow's triggers atomically with its new version. - workflow.Disable/Reenable toggle the triggers' enabled flag so a pruned/disabled workflow stops firing (schedulers key off workflow_triggers.enabled, not the definition's disabled_at) and a re-enabled unchanged workflow gets its triggers back. - The email poller periodically reconciles against the DB so email triggers registered by a later apply (a separate process) or a GitOps sync activate without a server restart. Cron and webhook triggers are already read live per tick/request. - Removed the dead server.SyncTriggers/TriggerInput; trigger-write logic now lives in the workflow package (avoids an import cycle). Adds integration tests covering registration, version replacement, the no-trigger case, and the disable/reenable toggle. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WnKgCRfMvFcZAkoVbr8k79
1 parent 1e87631 commit 0395d4c

6 files changed

Lines changed: 360 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), 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: 67 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -44,14 +44,31 @@ func Save(ctx context.Context, database *sql.DB, result *ParseResult, rawContent
4444
return 0, fmt.Errorf("marshaling workflow: %w", err)
4545
}
4646

47-
_, err = database.ExecContext(ctx,
47+
tx, err := database.BeginTx(ctx, nil)
48+
if err != nil {
49+
return 0, fmt.Errorf("starting transaction: %w", err)
50+
}
51+
defer tx.Rollback() //nolint:errcheck
52+
53+
_, err = tx.ExecContext(ctx,
4854
`INSERT INTO workflow_definitions (name, version, content, content_hash, team_id) VALUES ($1, $2, $3, $4, $5)`,
4955
name, newVersion, content, hash, teamID,
5056
)
5157
if err != nil {
5258
return 0, fmt.Errorf("inserting workflow definition: %w", err)
5359
}
5460

61+
// Register the workflow's declared triggers so the cron/webhook/email
62+
// 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 {
65+
return 0, err
66+
}
67+
68+
if err := tx.Commit(); err != nil {
69+
return 0, fmt.Errorf("committing workflow save: %w", err)
70+
}
71+
5572
return newVersion, nil
5673
}
5774

@@ -194,7 +211,13 @@ func GetVersion(ctx context.Context, database *sql.DB, name string, version int)
194211
// speculatively during a GitOps prune pass).
195212
func Disable(ctx context.Context, database *sql.DB, name string) error {
196213
teamID := auth.TeamIDFromContext(ctx)
197-
_, err := database.ExecContext(ctx,
214+
tx, err := database.BeginTx(ctx, nil)
215+
if err != nil {
216+
return fmt.Errorf("disabling workflow %q: %w", name, err)
217+
}
218+
defer tx.Rollback() //nolint:errcheck
219+
220+
if _, err := tx.ExecContext(ctx,
198221
`UPDATE workflow_definitions
199222
SET disabled_at = NOW()
200223
WHERE id = (
@@ -203,8 +226,23 @@ func Disable(ctx context.Context, database *sql.DB, name string) error {
203226
ORDER BY version DESC LIMIT 1
204227
)`,
205228
name, teamID,
206-
)
207-
if err != nil {
229+
); err != nil {
230+
return fmt.Errorf("disabling workflow %q: %w", name, err)
231+
}
232+
233+
// Disable the workflow's triggers too — the schedulers key off
234+
// workflow_triggers.enabled, not the definition's disabled_at, so a
235+
// pruned workflow would otherwise keep firing. Guarded on enabled = true
236+
// so repeated GitOps passes don't churn updated_at.
237+
if _, err := tx.ExecContext(ctx,
238+
`UPDATE workflow_triggers SET enabled = false, updated_at = NOW()
239+
WHERE workflow_name = $1 AND team_id = $2 AND enabled = true`,
240+
name, teamID,
241+
); err != nil {
242+
return fmt.Errorf("disabling triggers for %q: %w", name, err)
243+
}
244+
245+
if err := tx.Commit(); err != nil {
208246
return fmt.Errorf("disabling workflow %q: %w", name, err)
209247
}
210248
return nil
@@ -214,7 +252,13 @@ func Disable(ctx context.Context, database *sql.DB, name string) error {
214252
// workflow. Safe to call when already enabled — becomes a no-op.
215253
func Reenable(ctx context.Context, database *sql.DB, name string) error {
216254
teamID := auth.TeamIDFromContext(ctx)
217-
_, err := database.ExecContext(ctx,
255+
tx, err := database.BeginTx(ctx, nil)
256+
if err != nil {
257+
return fmt.Errorf("reenabling workflow %q: %w", name, err)
258+
}
259+
defer tx.Rollback() //nolint:errcheck
260+
261+
if _, err := tx.ExecContext(ctx,
218262
`UPDATE workflow_definitions
219263
SET disabled_at = NULL
220264
WHERE id = (
@@ -223,8 +267,24 @@ func Reenable(ctx context.Context, database *sql.DB, name string) error {
223267
ORDER BY version DESC LIMIT 1
224268
)`,
225269
name, teamID,
226-
)
227-
if err != nil {
270+
); err != nil {
271+
return fmt.Errorf("reenabling workflow %q: %w", name, err)
272+
}
273+
274+
// Restore the workflow's triggers. This covers the GitOps case where a
275+
// workflow was pruned (triggers disabled) and later reappears with
276+
// byte-identical content: Save is a no-op so it won't re-register them,
277+
// but Reenable must. Guarded on enabled = false to stay a true no-op on
278+
// the common already-enabled path.
279+
if _, err := tx.ExecContext(ctx,
280+
`UPDATE workflow_triggers SET enabled = true, updated_at = NOW()
281+
WHERE workflow_name = $1 AND team_id = $2 AND enabled = false`,
282+
name, teamID,
283+
); err != nil {
284+
return fmt.Errorf("reenabling triggers for %q: %w", name, err)
285+
}
286+
287+
if err := tx.Commit(); err != nil {
228288
return fmt.Errorf("reenabling workflow %q: %w", name, err)
229289
}
230290
return nil
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
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.
19+
func syncTriggersTx(ctx context.Context, tx *sql.Tx, teamID, name string, version int, triggers []Trigger) error {
20+
// A new version supersedes the previous one, so replace whatever was
21+
// registered before rather than trying to diff. Scoped to team so we
22+
// never touch another tenant's rows.
23+
if _, err := tx.ExecContext(ctx,
24+
`DELETE FROM workflow_triggers WHERE workflow_name = $1 AND team_id = $2`,
25+
name, teamID); err != nil {
26+
return fmt.Errorf("clearing triggers for %q: %w", name, err)
27+
}
28+
29+
for _, t := range triggers {
30+
if _, err := tx.ExecContext(ctx,
31+
`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+
name, version, t.Type,
35+
nullableString(t.Schedule), nullableString(t.Path), nullableString(t.Secret), teamID,
36+
nullableString(t.Mailbox), nullableString(t.Folder), nullableString(t.Filter), nullableString(t.PollInterval),
37+
); err != nil {
38+
return fmt.Errorf("registering %s trigger for %q: %w", t.Type, name, err)
39+
}
40+
}
41+
42+
return nil
43+
}
44+
45+
// nullableString maps an empty string to NULL so that nullable TEXT columns
46+
// receive NULL rather than an empty string. Reads COALESCE these back to
47+
// their defaults.
48+
func nullableString(s string) any {
49+
if s == "" {
50+
return nil
51+
}
52+
return s
53+
}

0 commit comments

Comments
 (0)