Skip to content

Commit c609237

Browse files
v0.4.0 — The Safety Net Update (#77)
* feat(config): add version field with validation (#52) Add a Version field to the Config struct, validated after Viper unmarshal: - Missing or 0 defaults to version 1 (backward compat) - Version 1 is accepted - Version 2+ returns a hard error directing users to upgrade mantle No env var binding — version is config-file-only. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(config): rename tmp section to storage with deprecation fallback (#75) The `tmp` config section and `TmpStorage` interface were misleadingly named -- they store execution artifacts, not temporary files. Rename to `storage`/ `StorageConfig`/`Storage` throughout the codebase. A deprecation fallback reads the old `tmp` YAML section and logs a warning, so existing configs continue to work during migration. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(secrets): global key rotation with caller-managed transaction (#51) Replace team-scoped ReEncryptAll with a package-level RotateAll function that operates globally across all teams and accepts a caller-managed transaction. Update the CLI rotate-key command to manage its own tx lifecycle (begin/commit/rollback). Add ActionSecretKeyRotated audit action. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(db): add migration 016 for v0.4.0 safety net features * feat(workflow): add concurrency fields, hooks schema, and validation (#49, #30) Add max_parallel_executions, on_limit, and hooks to Workflow struct; add max_parallel to Step struct; add HooksConfig type with on_success, on_failure, on_finish blocks. Validate all new fields including hook step uniqueness, depends_on rejection, and duration parsing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(engine): concurrency controls with advisory locks and queue promotion (#49) Add per-workflow and per-team concurrency limits using Postgres advisory locks. Executions that exceed limits are either queued or rejected based on the on_limit policy. Queued executions are promoted FIFO when a slot opens. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(cel): add hooks and execution variables to CEL environment (#30) Register `hooks` and `execution` as top-level CEL variables so hook steps can reference hook outputs (hooks.<name>.output) and execution metadata (execution.status, execution.failed_step, etc.). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(engine): lifecycle hooks — on_success, on_failure, on_finish (#30) Implement lifecycle hooks that run after main workflow steps complete. Hook failures are best-effort and never alter workflow execution status. - Add hook audit actions (hook.step.started/completed/failed) - Add hook Prometheus metrics (hook_steps_total, hook_steps_failed_total) - Exclude hook steps from main DAG query (hook_block IS NULL filter) - Create hooks.go with executeHooks/executeHookBlock/recordHookStep - Wire hooks into resumeExecution with timeout/cancellation handling Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(engine): promote queued executions after hooks complete (#49, #30) * feat(cli): mantle retry — resume from failed step (#48) Add `mantle retry <execution-id>` command that creates a new execution resuming from the failure point of a previous run. Completed upstream steps are copied from the original; the failed step and downstream re-execute with fresh hooks. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(cli): mantle rollback — revert workflow to previous version (#50) Add `mantle rollback <workflow> [--to-version N]` command that creates a new version with the content of a previous version. Preserves full version history via rollback_of column. Default target is the second most recent version. Emits workflow.rolled_back audit event. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: document v0.4.0 features — hooks, concurrency, retry, rollback Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address code review findings — transactions, validation, indexes - Wrap rollback read+insert in a transaction to prevent race conditions - Add name format, timeout, and retry validation to hook steps - Log audit emit errors instead of silently discarding them - Add partial indexes for retry lookups and hook step filtering Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: use fresh context for queue promotion after timeout (security review) * fix: wire concurrency limits into execution path (architecture review) * fix: store unprefixed hook step names with proper unique constraint Replace the monolithic UNIQUE(execution_id, step_name, attempt) constraint with two partial indexes — one for main steps (WHERE hook_block IS NULL) and one for hook steps (WHERE hook_block IS NOT NULL). This lets hook steps use their natural names instead of "on_failure:cleanup" prefixes, with the hook_block column providing disambiguation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: add unit tests for concurrency, hooks, and retry engine logic Also fixes the execution status check constraint to include 'queued' and 'timed_out' statuses needed by the concurrency control feature. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: add stderr warning before printing sensitive key material * fix: secure key rotation — no argv exposure, tx-backed audit, advisory lock (PR review) - Replace --new-key flag with --new-key-file to avoid exposing secrets in process arguments; add --output-key for secure file-based key output - Never echo back user-provided keys; only print auto-generated keys - Move audit emission inside the transaction via new audit.EmitTx() so the audit record commits atomically with the rotation - Use context.Background() for audit emit to prevent cancellation loss - Add pg_advisory_xact_lock in RotateAll to serialize with credential writers - Add defer tx.Rollback() in tests; upgrade count assertions to t.Fatalf Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: atomic concurrency check+insert, audit on promotion (PR review) Close TOCTOU race where advisory lock was released between concurrency check and execution insert by performing both in the same transaction. Add createExecutionTx for transactional inserts. Update PromoteQueued and PromoteQueuedByTeam to emit audit events and return promoted IDs. Add PromotionFailuresTotal metric and ActionExecutionPromoted constant. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: hooks hardening, migration fixes, validation, security improvements (PR review) - Default 5m timeout on hook parse failure instead of proceeding without one - Atomic variables in hooks_test.go; fix vacuous on_success assertion with request counter - Remove redundant HookStepsFailedTotal metric (use HookStepsTotal status=failed) - Down migration: partial unique index, data migration for queued/timed_out statuses - Persist timed_out as terminal status in updateExecutionStatus - Deterministic failed-step lookup via orderedSteps; DISTINCT ON in retry step copy - Nil check on v.Sub("tmp") in config fallback - CEL expression validation for hook step params and if conditions - Add hook_block IS NULL to toolsteps fallback SELECT - Improved --force flag help text - Code block language specifiers in workflow-commands.md - Add version: 1 to Production and Server Mode YAML examples - Symlink-aware path validation in FilesystemStorage (EvalSymlinks) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: PR review round 2 — config semantics, exit codes, audit, security, validation - Config: distinguish missing vs explicit version 0 via v.IsSet; field-by-field tmp->storage merge to preserve env/flag overrides - CLI: run/retry exit non-zero on failed/timed_out/cancelled; JSON output no longer masks failure exit codes - Retry: set MaxConcurrentExecutionsPerTeam; audit emitted inside transaction; link+copy+audit in single tx - Hooks: failed_in defaults to "" (empty), set to "steps" only when main steps fail; hook failures update to block name as before - Audit: EmitTx accepts optional PostgresEmitter for context enrichment - Storage: reject absolute/.. keys before MkdirAll; fix symlink fallback in Delete/DeleteByPrefix to use resolvedBase - Validation: cross-block hook step name uniqueness (shared CEL ns) - Secret store: Create acquires same advisory lock as RotateAll - Engine: step-level timeout detection via errors.Is(DeadlineExceeded) returns timed_out status; migration adds timed_out to step status - Migration down: delete hook rows before dropping column, restore proper unique constraint instead of partial index - Tests: team-level concurrency, updated timeout/validation expectations - Docs: --force description corrected for retry command Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: JSON encode errors, encrypt under lock, docs accuracy (PR review round 3) - Handle JSON encoding errors in retry.go and run.go instead of discarding - Move credential encryption inside advisory lock in store.Create to prevent race with concurrent RotateAll - Fix misleading --force error example in CLI docs Not applied: --output flag registration (already exists as persistent flag on root command), max_concurrent_executions_per_team default of 10 (spec says 0 = unlimited, Go zero value is correct) * fix: match rollback error example to actual code output --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 81daf35 commit c609237

40 files changed

Lines changed: 3327 additions & 178 deletions

CLAUDE.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,10 +68,13 @@ mantle validate workflow.yaml # Offline schema validation
6868
mantle plan workflow.yaml # Diff against applied version
6969
mantle apply workflow.yaml # Apply versioned definition
7070
mantle run <workflow> # Manual trigger
71-
mantle cancel <execution-id> # Cancel running workflow
71+
mantle cancel <execution-id> # Cancel running workflow
72+
mantle retry <execution-id> # Retry from failed step
73+
mantle rollback <workflow> # Rollback to previous version
7274
mantle logs <execution-id> # View execution logs
7375
mantle status <execution-id> # View execution state
7476
mantle secrets create # Create typed credential
77+
mantle secrets rotate-key # Re-encrypt credentials with new key
7578
mantle serve # Start persistent server
7679
```
7780

packages/engine/docker-compose.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,9 @@ services:
5757
MANTLE_DATABASE_URL: postgres://mantle:mantle@postgres:5432/mantle?sslmode=disable
5858
MANTLE_API_ADDRESS: ":8080"
5959
MANTLE_ENCRYPTION_KEY: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
60-
MANTLE_TMP_TYPE: s3
61-
MANTLE_TMP_BUCKET: mantle-artifacts
62-
MANTLE_TMP_PREFIX: artifacts/
60+
MANTLE_STORAGE_TYPE: s3
61+
MANTLE_STORAGE_BUCKET: mantle-artifacts
62+
MANTLE_STORAGE_PREFIX: artifacts/
6363
# Point S3 client at MinIO
6464
AWS_ACCESS_KEY_ID: minioadmin
6565
AWS_SECRET_ACCESS_KEY: minioadmin

packages/engine/internal/artifact/reaper.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@ import (
99
"time"
1010
)
1111

12-
// Reaper cleans up expired artifacts from both tmp storage and the database.
12+
// Reaper cleans up expired artifacts from both storage and the database.
1313
type Reaper struct {
1414
Store *Store
15-
TmpStorage TmpStorage
15+
Storage Storage
1616
Retention time.Duration
1717
Logger *slog.Logger
1818
}
@@ -40,8 +40,8 @@ func (r *Reaper) Sweep(ctx context.Context) (int, error) {
4040

4141
cleaned := 0
4242
for _, a := range expired {
43-
// Delete file from tmp storage.
44-
if delErr := r.TmpStorage.Delete(ctx, a.URL); delErr != nil {
43+
// Delete file from storage.
44+
if delErr := r.Storage.Delete(ctx, a.URL); delErr != nil {
4545
// If the blob is already gone, still clean up the metadata.
4646
if !errors.Is(delErr, os.ErrNotExist) {
4747
logger.Error("failed to delete artifact file",

packages/engine/internal/artifact/reaper_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import (
1010

1111
func TestReaper_CleansExpiredArtifacts(t *testing.T) {
1212
dir := t.TempDir()
13-
fs := &FilesystemTmpStorage{BasePath: dir}
13+
fs := &FilesystemStorage{BasePath: dir}
1414
db := setupTestDB(t)
1515
store := &Store{DB: db}
1616
ctx := context.Background()
@@ -48,7 +48,7 @@ func TestReaper_CleansExpiredArtifacts(t *testing.T) {
4848

4949
reaper := &Reaper{
5050
Store: store,
51-
TmpStorage: fs,
51+
Storage: fs,
5252
Retention: 24 * time.Hour,
5353
}
5454

@@ -72,11 +72,11 @@ func TestReaper_CleansExpiredArtifacts(t *testing.T) {
7272

7373
func TestReaper_SkipsWhenRetentionZero(t *testing.T) {
7474
dir := t.TempDir()
75-
fs := &FilesystemTmpStorage{BasePath: dir}
75+
fs := &FilesystemStorage{BasePath: dir}
7676

7777
reaper := &Reaper{
7878
Store: &Store{}, // safe: Sweep returns early when Retention <= 0, before accessing Store.DB
79-
TmpStorage: fs,
79+
Storage: fs,
8080
Retention: 0,
8181
}
8282

packages/engine/internal/artifact/tmp.go renamed to packages/engine/internal/artifact/storage.go

Lines changed: 49 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@ import (
99
"strings"
1010
)
1111

12-
// TmpStorage persists and retrieves artifact files.
13-
type TmpStorage interface {
14-
// Put copies a local file to tmp storage at the given key.
12+
// Storage persists and retrieves artifact files.
13+
type Storage interface {
14+
// Put copies a local file to storage at the given key.
1515
// Returns the URL or path where the file can be accessed.
1616
Put(ctx context.Context, key string, localPath string) (string, error)
1717

@@ -22,32 +22,39 @@ type TmpStorage interface {
2222
DeleteByPrefix(ctx context.Context, prefix string) error
2323
}
2424

25-
// FilesystemTmpStorage stores artifacts on the local filesystem.
26-
type FilesystemTmpStorage struct {
25+
// FilesystemStorage stores artifacts on the local filesystem.
26+
type FilesystemStorage struct {
2727
BasePath string
2828
}
2929

3030
// Put copies the file at localPath to the storage location identified by key and returns its path.
31-
func (fs *FilesystemTmpStorage) Put(ctx context.Context, key string, localPath string) (string, error) {
31+
func (fs *FilesystemStorage) Put(ctx context.Context, key string, localPath string) (string, error) {
32+
// Reject obviously malicious keys before any filesystem operations.
33+
if filepath.IsAbs(key) || key == ".." || strings.HasPrefix(key, ".."+string(filepath.Separator)) {
34+
return "", fmt.Errorf("invalid artifact key: must be a relative path within the storage directory")
35+
}
36+
3237
destPath := filepath.Join(fs.BasePath, key)
3338
// Validate destination is within BasePath to prevent path traversal.
34-
absBase, err := filepath.Abs(fs.BasePath)
39+
// Use EvalSymlinks for canonicalization to prevent symlink-based escapes.
40+
resolvedBase, err := filepath.EvalSymlinks(fs.BasePath)
3541
if err != nil {
3642
return "", fmt.Errorf("resolving base path: %w", err)
3743
}
38-
absDest, err := filepath.Abs(destPath)
44+
// Ensure dest directory exists before resolving symlinks on the full path.
45+
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
46+
return "", fmt.Errorf("creating artifact dir: %w", err)
47+
}
48+
resolvedDest, err := filepath.EvalSymlinks(filepath.Dir(destPath))
3949
if err != nil {
4050
return "", fmt.Errorf("resolving dest path: %w", err)
4151
}
42-
rel, err := filepath.Rel(absBase, absDest)
52+
resolvedDest = filepath.Join(resolvedDest, filepath.Base(destPath))
53+
rel, err := filepath.Rel(resolvedBase, resolvedDest)
4354
if err != nil || strings.HasPrefix(rel, "..") {
4455
return "", fmt.Errorf("key escapes base path")
4556
}
4657

47-
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
48-
return "", fmt.Errorf("creating artifact dir: %w", err)
49-
}
50-
5158
// Reject symlinks and non-regular files to prevent exfiltration.
5259
fi, err := os.Lstat(localPath)
5360
if err != nil {
@@ -79,38 +86,57 @@ func (fs *FilesystemTmpStorage) Put(ctx context.Context, key string, localPath s
7986
}
8087

8188
// DeleteByPrefix removes all files stored under the given key prefix.
82-
func (fs *FilesystemTmpStorage) DeleteByPrefix(ctx context.Context, prefix string) error {
89+
func (fs *FilesystemStorage) DeleteByPrefix(ctx context.Context, prefix string) error {
8390
target := filepath.Join(fs.BasePath, prefix)
84-
absBase, err := filepath.Abs(fs.BasePath)
91+
resolvedBase, err := filepath.EvalSymlinks(fs.BasePath)
8592
if err != nil {
8693
return fmt.Errorf("resolving base path: %w", err)
8794
}
88-
absTarget, err := filepath.Abs(target)
95+
resolvedTarget, err := filepath.EvalSymlinks(target)
8996
if err != nil {
97+
// Target doesn't exist — compute candidate relative to resolvedBase for traversal check.
98+
candidate := filepath.Join(resolvedBase, prefix)
99+
rel, relErr := filepath.Rel(resolvedBase, candidate)
100+
if relErr != nil || strings.HasPrefix(rel, "..") {
101+
return fmt.Errorf("prefix escapes base path")
102+
}
103+
// Target doesn't exist and is within base — nothing to delete.
104+
if os.IsNotExist(err) {
105+
return nil
106+
}
90107
return fmt.Errorf("resolving target path: %w", err)
91108
}
92-
rel, err := filepath.Rel(absBase, absTarget)
109+
rel, err := filepath.Rel(resolvedBase, resolvedTarget)
93110
if err != nil || strings.HasPrefix(rel, "..") {
94111
return fmt.Errorf("prefix escapes base path")
95112
}
96-
return os.RemoveAll(target)
113+
return os.RemoveAll(resolvedTarget)
97114
}
98115

99116
// Delete removes a single artifact file by its path (as returned by Put).
100-
func (fs *FilesystemTmpStorage) Delete(ctx context.Context, url string) error {
101-
absBase, err := filepath.Abs(fs.BasePath)
117+
func (fs *FilesystemStorage) Delete(ctx context.Context, url string) error {
118+
resolvedBase, err := filepath.EvalSymlinks(fs.BasePath)
102119
if err != nil {
103120
return fmt.Errorf("resolving base path: %w", err)
104121
}
105-
absURL, err := filepath.Abs(url)
122+
resolvedURL, err := filepath.EvalSymlinks(url)
106123
if err != nil {
124+
// File doesn't exist — compute path relative to resolvedBase for traversal check.
125+
// url is an absolute path returned by Put, so use it directly.
126+
rel, relErr := filepath.Rel(resolvedBase, url)
127+
if relErr != nil || strings.HasPrefix(rel, "..") {
128+
return fmt.Errorf("artifact path escapes base path")
129+
}
130+
if os.IsNotExist(err) {
131+
return nil // already gone
132+
}
107133
return fmt.Errorf("resolving artifact path: %w", err)
108134
}
109-
rel, err := filepath.Rel(absBase, absURL)
135+
rel, err := filepath.Rel(resolvedBase, resolvedURL)
110136
if err != nil || strings.HasPrefix(rel, "..") {
111137
return fmt.Errorf("artifact path escapes base path")
112138
}
113-
if err := os.Remove(absURL); err != nil && !os.IsNotExist(err) {
139+
if err := os.Remove(resolvedURL); err != nil && !os.IsNotExist(err) {
114140
return err
115141
}
116142
return nil

packages/engine/internal/artifact/tmp_test.go renamed to packages/engine/internal/artifact/storage_test.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@ import (
77
"testing"
88
)
99

10-
func TestFilesystemTmpStorage_PutAndGet(t *testing.T) {
10+
func TestFilesystemStorage_PutAndGet(t *testing.T) {
1111
dir := t.TempDir()
12-
fs := &FilesystemTmpStorage{BasePath: dir}
12+
fs := &FilesystemStorage{BasePath: dir}
1313

1414
ctx := context.Background()
1515
key := "test-workflow/exec-123/my-artifact/data.tar.gz"
@@ -40,9 +40,9 @@ func TestFilesystemTmpStorage_PutAndGet(t *testing.T) {
4040
}
4141
}
4242

43-
func TestFilesystemTmpStorage_Delete(t *testing.T) {
43+
func TestFilesystemStorage_Delete(t *testing.T) {
4444
dir := t.TempDir()
45-
fs := &FilesystemTmpStorage{BasePath: dir}
45+
fs := &FilesystemStorage{BasePath: dir}
4646
ctx := context.Background()
4747

4848
// Write a file
@@ -68,9 +68,9 @@ func TestFilesystemTmpStorage_Delete(t *testing.T) {
6868
}
6969
}
7070

71-
func TestFilesystemTmpStorage_DeleteEscapeProtection(t *testing.T) {
71+
func TestFilesystemStorage_DeleteEscapeProtection(t *testing.T) {
7272
dir := t.TempDir()
73-
fs := &FilesystemTmpStorage{BasePath: dir}
73+
fs := &FilesystemStorage{BasePath: dir}
7474
ctx := context.Background()
7575

7676
err := fs.DeleteByPrefix(ctx, "../../etc")

packages/engine/internal/audit/audit.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const (
1717
ActionStepSkipped Action = "step.skipped"
1818
ActionStepContinuedOnError Action = "step.continued_on_error"
1919
ActionExecutionCancelled Action = "execution.cancelled"
20+
ActionExecutionRetried Action = "execution.retried"
2021
ActionArtifactPersisted Action = "artifact.persisted"
2122

2223
// Admin operations.
@@ -29,18 +30,30 @@ const (
2930
ActionAPIKeyRevoked Action = "apikey.revoked"
3031
ActionCredentialCreated Action = "credential.created"
3132
ActionCredentialDeleted Action = "credential.deleted"
32-
ActionCredentialRotated Action = "credential.rotated"
33+
ActionCredentialRotated Action = "credential.rotated"
34+
ActionSecretKeyRotated Action = "secret.key_rotated"
3335
ActionAuthFailed Action = "auth.failed"
3436

3537
// Email trigger operations.
3638
ActionEmailTriggerFired Action = "email.trigger.fired"
3739
ActionEmailConnectionEstablished Action = "email.connection.established"
3840
ActionEmailConnectionFailed Action = "email.connection.failed"
3941

42+
// Hook operations.
43+
ActionHookStepStarted Action = "hook.step.started"
44+
ActionHookStepCompleted Action = "hook.step.completed"
45+
ActionHookStepFailed Action = "hook.step.failed"
46+
4047
// Budget operations.
4148
ActionBudgetExceeded Action = "budget.exceeded"
4249
ActionBudgetWarning Action = "budget.warning"
4350
ActionBudgetUpdated Action = "budget.updated"
51+
52+
// Promotion operations.
53+
ActionExecutionPromoted Action = "execution.promoted"
54+
55+
// Rollback operations.
56+
ActionWorkflowRolledBack Action = "workflow.rolled_back"
4457
)
4558

4659
// Resource identifies the target of an audit event.

packages/engine/internal/audit/postgres.go

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,25 @@ func (p *PostgresEmitter) enrichFromContext(ctx context.Context, event Event) Ev
3333
func (p *PostgresEmitter) Emit(ctx context.Context, event Event) error {
3434
// Enrich event metadata with auth context if available.
3535
event = p.enrichFromContext(ctx, event)
36+
return emitEvent(ctx, p.DB, event)
37+
}
38+
39+
// execer abstracts ExecContext so both *sql.DB and *sql.Tx can emit audit events.
40+
type execer interface {
41+
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
42+
}
43+
44+
// EmitTx emits an audit event using an existing transaction.
45+
// This ensures the audit record is committed atomically with the operation it describes.
46+
// If emitter is non-nil, the event is enriched with auth context before inserting.
47+
func EmitTx(ctx context.Context, tx *sql.Tx, event Event, emitter ...*PostgresEmitter) error {
48+
if len(emitter) > 0 && emitter[0] != nil {
49+
event = emitter[0].enrichFromContext(ctx, event)
50+
}
51+
return emitEvent(ctx, tx, event)
52+
}
53+
54+
func emitEvent(ctx context.Context, db execer, event Event) error {
3655
beforeJSON, err := marshalNullableJSON(event.Before)
3756
if err != nil {
3857
return fmt.Errorf("marshaling before state: %w", err)
@@ -61,7 +80,7 @@ func (p *PostgresEmitter) Emit(ctx context.Context, event Event) error {
6180
teamID = event.TeamID
6281
}
6382

64-
_, err = p.DB.ExecContext(ctx,
83+
_, err = db.ExecContext(ctx,
6584
`INSERT INTO audit_events (timestamp, actor, action, resource_type, resource_id, before_state, after_state, metadata, team_id)
6685
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
6786
ts, event.Actor, string(event.Action), event.Resource.Type, event.Resource.ID,

packages/engine/internal/cel/cel.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ type Context struct {
1818
Inputs map[string]any // inputs.<name> → workflow inputs
1919
Trigger map[string]any // trigger.payload → webhook trigger data
2020
Artifacts map[string]map[string]any // artifacts.<name> → {name, url, size}
21+
Hooks map[string]map[string]any // hooks.<name>.output → hook step outputs
22+
Execution map[string]any // execution.status, execution.error, etc.
2123
}
2224

2325
const maxProgramCacheSize = 10000
@@ -38,6 +40,8 @@ func NewEvaluator() (*Evaluator, error) {
3840
cel.Variable("env", cel.MapType(cel.StringType, cel.StringType)),
3941
cel.Variable("trigger", cel.MapType(cel.StringType, cel.DynType)),
4042
cel.Variable("artifacts", cel.MapType(cel.StringType, cel.DynType)),
43+
cel.Variable("hooks", cel.MapType(cel.StringType, cel.DynType)),
44+
cel.Variable("execution", cel.MapType(cel.StringType, cel.DynType)),
4145
}
4246
opts = append(opts, customFunctions()...)
4347

@@ -66,12 +70,24 @@ func (e *Evaluator) Eval(expression string, ctx *Context) (any, error) {
6670
artifacts = map[string]map[string]any{}
6771
}
6872

73+
hooks := ctx.Hooks
74+
if hooks == nil {
75+
hooks = map[string]map[string]any{}
76+
}
77+
78+
execution := ctx.Execution
79+
if execution == nil {
80+
execution = map[string]any{}
81+
}
82+
6983
vars := map[string]any{
7084
"steps": ctx.Steps,
7185
"inputs": ctx.Inputs,
7286
"env": e.envCache,
7387
"trigger": trigger,
7488
"artifacts": artifacts,
89+
"hooks": hooks,
90+
"execution": execution,
7591
}
7692

7793
out, _, err := prog.Eval(vars)

0 commit comments

Comments
 (0)