semantic validator

This commit is contained in:
booleanmaybe 2026-04-07 23:42:47 -04:00
parent 7d48d538c3
commit 7957655487
14 changed files with 1653 additions and 115 deletions

View file

@ -209,6 +209,7 @@ Ruki has these built-ins:
| `now()` | `timestamp` | 0 | no additional validation |
| `next_date(...)` | `date` | exactly 1 | argument must be `recurrence` |
| `blocks(...)` | `list<ref>` | exactly 1 | argument must be `id`, `ref`, or string literal |
| `id()` | `id` | 0 | valid only in plugin runtime; resolves to selected tiki ID |
| `call(...)` | `string` | exactly 1 | argument must be `string` |
| `user()` | `string` | 0 | no additional validation |
@ -219,10 +220,18 @@ select where count(select where status = "done") >= 1
select where updatedAt < now()
create title="x" due=next_date(recurrence)
select where blocks(id) is empty
select where id() in dependsOn
create title=call("echo hi")
select where assignee = user()
```
Runtime notes:
- `id()` is semantically valid only in plugin runtime.
- When a validated statement uses `id()`, plugin execution must provide a non-empty selected task ID.
- `id()` is rejected for CLI, event-trigger, and time-trigger semantic runtimes.
- `call(...)` is currently rejected by semantic validation.
`run(...)`
- not a normal expression built-in

View file

@ -29,17 +29,18 @@ func RunQuery(gate *service.TaskMutationGate, query string, out io.Writer) error
if err != nil {
return fmt.Errorf("resolve current user: %w", err)
}
executor := ruki.NewExecutor(schema, func() string { return userName })
executor := ruki.NewExecutor(schema, func() string { return userName }, ruki.ExecutorRuntime{Mode: ruki.ExecutorRuntimeCLI})
stmt, err := parser.ParseStatement(query)
stmt, err := parser.ParseAndValidateStatement(query, ruki.ExecutorRuntimeCLI)
if err != nil {
return fmt.Errorf("parse: %w", err)
}
// for CREATE, fetch template before execution so field references
// (e.g. tags=tags+["new"]) resolve from template defaults
var template *task.Task
if stmt.Create != nil {
var input ruki.ExecutionInput
if stmt.RequiresCreateTemplate() {
var template *task.Task
template, err = readStore.NewTaskTemplate()
if err != nil {
return fmt.Errorf("create template: %w", err)
@ -47,11 +48,11 @@ func RunQuery(gate *service.TaskMutationGate, query string, out io.Writer) error
if template == nil {
return fmt.Errorf("create template: store returned nil template")
}
executor.SetTemplate(template)
input.CreateTemplate = template
}
tasks := readStore.GetAllTasks()
result, err := executor.Execute(stmt, tasks)
result, err := executor.Execute(stmt, tasks, input)
if err != nil {
return fmt.Errorf("execute: %w", err)
}
@ -67,7 +68,7 @@ func RunQuery(gate *service.TaskMutationGate, query string, out io.Writer) error
return persistAndSummarize(ctx, gate, result.Update, out)
case result.Create != nil:
return persistCreate(ctx, gate, result.Create, template, out)
return persistCreate(ctx, gate, result.Create, out)
case result.Delete != nil:
return persistDelete(ctx, gate, result.Delete, out)
@ -96,15 +97,19 @@ func RunSelectQuery(readStore store.ReadStore, query string, out io.Writer) erro
if stmt.Select == nil {
return fmt.Errorf("RunSelectQuery only supports SELECT statements")
}
validated, err := ruki.NewSemanticValidator(ruki.ExecutorRuntimeCLI).ValidateStatement(stmt)
if err != nil {
return fmt.Errorf("semantic validate: %w", err)
}
userName, err := resolveUser(readStore)
if err != nil {
return fmt.Errorf("resolve current user: %w", err)
}
executor := ruki.NewExecutor(schema, func() string { return userName })
executor := ruki.NewExecutor(schema, func() string { return userName }, ruki.ExecutorRuntime{Mode: ruki.ExecutorRuntimeCLI})
tasks := readStore.GetAllTasks()
result, err := executor.Execute(stmt, tasks)
result, err := executor.Execute(validated, tasks, ruki.ExecutionInput{})
if err != nil {
return fmt.Errorf("execute: %w", err)
}
@ -137,11 +142,8 @@ func persistAndSummarize(ctx context.Context, gate *service.TaskMutationGate, ur
return nil
}
func persistCreate(ctx context.Context, gate *service.TaskMutationGate, cr *ruki.CreateResult, template *task.Task, out io.Writer) error {
func persistCreate(ctx context.Context, gate *service.TaskMutationGate, cr *ruki.CreateResult, out io.Writer) error {
t := cr.Task
t.ID = template.ID
t.CreatedBy = template.CreatedBy
t.CreatedAt = template.CreatedAt
if err := gate.CreateTask(ctx, t); err != nil {
return fmt.Errorf("create task: %w", err)

View file

@ -335,14 +335,14 @@ func TestRunQueryResolveUserErrorUpdate(t *testing.T) {
func TestRunQueryExecuteError(t *testing.T) {
s := setupRunnerTest(t)
// call() is rejected at runtime, triggering an execute error
// call() is rejected during semantic validation in RunQuery.
var buf bytes.Buffer
err := RunQuery(gateFor(s), `select where call("echo") = "x"`, &buf)
if err == nil {
t.Fatal("expected execute error")
t.Fatal("expected semantic validation error")
}
if !strings.Contains(err.Error(), "execute") {
t.Errorf("expected execute error, got: %v", err)
if !strings.Contains(err.Error(), "call() is not supported yet") {
t.Errorf("expected call() semantic validation error, got: %v", err)
}
}
@ -354,8 +354,8 @@ func TestRunQueryUpdateInvalidPointsE2E(t *testing.T) {
if err == nil {
t.Fatal("expected error for invalid points")
}
if !strings.Contains(err.Error(), "invalid points") {
t.Errorf("expected 'invalid points' error, got: %v", err)
if !strings.Contains(err.Error(), "points value out of range") {
t.Errorf("expected points range error, got: %v", err)
}
}
@ -656,14 +656,14 @@ func TestRunQueryDeleteSilentFailure(t *testing.T) {
func TestRunSelectQueryExecuteError(t *testing.T) {
s := setupRunnerTest(t)
// call() is not supported at runtime, causing execute error
// call() is rejected during semantic validation in RunSelectQuery.
var buf bytes.Buffer
err := RunSelectQuery(s, `select where call("echo") = "x"`, &buf)
if err == nil {
t.Fatal("expected execute error")
t.Fatal("expected semantic validation error")
}
if !strings.Contains(err.Error(), "execute") {
t.Errorf("expected execute error, got: %v", err)
if !strings.Contains(err.Error(), "call() is not supported yet") {
t.Errorf("expected call() semantic validation error, got: %v", err)
}
}

View file

@ -12,23 +12,23 @@ import (
// Executor evaluates parsed ruki statements against a set of tasks.
type Executor struct {
schema Schema
userFunc func() string
template *task.Task
schema Schema
userFunc func() string
runtime ExecutorRuntime
currentInput ExecutionInput
}
// SetTemplate sets the base task template for CREATE execution.
// When set, CREATE assignments are evaluated against a clone of this template,
// so field references (e.g. tags=tags+["new"]) resolve from template defaults.
func (e *Executor) SetTemplate(t *task.Task) { e.template = t }
// NewExecutor constructs an Executor with the given schema and user function.
// If userFunc is nil, calling user() at runtime will return "".
func NewExecutor(schema Schema, userFunc func() string) *Executor {
func NewExecutor(schema Schema, userFunc func() string, runtime ExecutorRuntime) *Executor {
if userFunc == nil {
userFunc = func() string { return "" }
}
return &Executor{schema: schema, userFunc: userFunc}
return &Executor{
schema: schema,
userFunc: userFunc,
runtime: runtime.normalize(),
}
}
// Result holds the output of executing a statement.
@ -62,19 +62,67 @@ type TaskProjection struct {
}
// Execute dispatches on the statement type and returns results.
func (e *Executor) Execute(stmt *Statement, tasks []*task.Task) (*Result, error) {
// Preferred input is *ValidatedStatement; raw *Statement is accepted as a
// low-level path and will be semantically validated for executor runtime mode.
func (e *Executor) Execute(stmt any, tasks []*task.Task, inputs ...ExecutionInput) (*Result, error) {
var input ExecutionInput
if len(inputs) > 0 {
input = inputs[0]
}
if stmt == nil {
return nil, fmt.Errorf("nil statement")
}
var validated *ValidatedStatement
var rawStmt *Statement
rawInput := false
requiresCreateTemplate := false
switch s := stmt.(type) {
case *ValidatedStatement:
validated = s
requiresCreateTemplate = true
case *Statement:
rawInput = true
var err error
validated, err = NewSemanticValidator(e.runtime.Mode).ValidateStatement(s)
if err != nil {
return nil, err
}
default:
return nil, fmt.Errorf("unsupported statement type %T", stmt)
}
if validated != nil {
if err := validated.mustBeSealed(); err != nil {
return nil, err
}
if validated.runtime != e.runtime.Mode {
return nil, &RuntimeMismatchError{
ValidatedFor: validated.runtime,
Runtime: e.runtime.Mode,
}
}
if validated.usesIDFunc && e.runtime.Mode == ExecutorRuntimePlugin && strings.TrimSpace(input.SelectedTaskID) == "" {
return nil, &MissingSelectedTaskIDError{}
}
rawStmt = validated.statement
if rawInput {
requiresCreateTemplate = false
}
}
e.currentInput = input
defer func() { e.currentInput = ExecutionInput{} }()
switch {
case stmt.Select != nil:
return e.executeSelect(stmt.Select, tasks)
case stmt.Create != nil:
return e.executeCreate(stmt.Create, tasks)
case stmt.Update != nil:
return e.executeUpdate(stmt.Update, tasks)
case stmt.Delete != nil:
return e.executeDelete(stmt.Delete, tasks)
case rawStmt.Select != nil:
return e.executeSelect(rawStmt.Select, tasks)
case rawStmt.Create != nil:
return e.executeCreate(rawStmt.Create, tasks, requiresCreateTemplate)
case rawStmt.Update != nil:
return e.executeUpdate(rawStmt.Update, tasks)
case rawStmt.Delete != nil:
return e.executeDelete(rawStmt.Delete, tasks)
default:
return nil, fmt.Errorf("empty statement")
}
@ -124,10 +172,13 @@ func (e *Executor) executeUpdate(upd *UpdateStmt, tasks []*task.Task) (*Result,
return &Result{Update: &UpdateResult{Updated: clones}}, nil
}
func (e *Executor) executeCreate(cr *CreateStmt, tasks []*task.Task) (*Result, error) {
func (e *Executor) executeCreate(cr *CreateStmt, tasks []*task.Task, requireTemplate bool) (*Result, error) {
if requireTemplate && e.currentInput.CreateTemplate == nil {
return nil, &MissingCreateTemplateError{}
}
var t *task.Task
if e.template != nil {
t = e.template.Clone()
if e.currentInput.CreateTemplate != nil {
t = e.currentInput.CreateTemplate.Clone()
} else {
t = &task.Task{}
}
@ -551,6 +602,8 @@ func (e *Executor) evalListLiteral(ll *ListLiteral, t *task.Task, allTasks []*ta
func (e *Executor) evalFunctionCall(fc *FunctionCall, t *task.Task, allTasks []*task.Task) (interface{}, error) {
switch fc.Name {
case "id":
return e.evalID()
case "now":
return time.Now(), nil
case "user":
@ -568,6 +621,17 @@ func (e *Executor) evalFunctionCall(fc *FunctionCall, t *task.Task, allTasks []*
}
}
func (e *Executor) evalID() (interface{}, error) {
if e.runtime.Mode != ExecutorRuntimePlugin {
return nil, fmt.Errorf("id() is only available in plugin runtime")
}
id := strings.TrimSpace(e.currentInput.SelectedTaskID)
if id == "" {
return nil, &MissingSelectedTaskIDError{}
}
return id, nil
}
func (e *Executor) evalCount(fc *FunctionCall, allTasks []*task.Task) (interface{}, error) {
sq, ok := fc.Args[0].(*SubQuery)
if !ok {
@ -875,6 +939,11 @@ func (e *Executor) exprFieldType(expr Expr) ValueType {
name = e.Name
case *QualifiedRef:
name = e.Name
case *FunctionCall:
if e.Name == "id" {
return ValueID
}
return -1
default:
return -1
}

74
ruki/executor_runtime.go Normal file
View file

@ -0,0 +1,74 @@
package ruki
import (
"errors"
"fmt"
"github.com/boolean-maybe/tiki/task"
)
// ExecutorRuntimeMode identifies the semantic/runtime environment in which
// a validated AST is intended to execute.
type ExecutorRuntimeMode string
const (
ExecutorRuntimeCLI ExecutorRuntimeMode = "cli"
ExecutorRuntimePlugin ExecutorRuntimeMode = "plugin"
ExecutorRuntimeEventTrigger ExecutorRuntimeMode = "eventTrigger"
ExecutorRuntimeTimeTrigger ExecutorRuntimeMode = "timeTrigger"
)
// ExecutorRuntime configures executor identity/runtime semantics.
// Per-execution payload (e.g. selected task id, create template) is passed
// via ExecutionInput and is intentionally not part of this struct.
type ExecutorRuntime struct {
Mode ExecutorRuntimeMode
}
// normalize returns a runtime with defaults applied.
func (r ExecutorRuntime) normalize() ExecutorRuntime {
if r.Mode == "" {
r.Mode = ExecutorRuntimeCLI
}
return r
}
// ExecutionInput carries per-execution payload that is not part of executor
// runtime identity.
type ExecutionInput struct {
SelectedTaskID string
CreateTemplate *task.Task
}
// RuntimeMismatchError reports execution with a wrapper validated for a
// different runtime mode.
type RuntimeMismatchError struct {
ValidatedFor ExecutorRuntimeMode
Runtime ExecutorRuntimeMode
}
func (e *RuntimeMismatchError) Error() string {
return fmt.Sprintf("validated runtime %q does not match executor runtime %q", e.ValidatedFor, e.Runtime)
}
func (e *RuntimeMismatchError) Unwrap() error { return ErrRuntimeMismatch }
// MissingSelectedTaskIDError reports plugin execution that requires selected id
// (due to syntactic id() usage) but did not receive it.
type MissingSelectedTaskIDError struct{}
func (e *MissingSelectedTaskIDError) Error() string {
return "selected task id is required for plugin runtime when id() is used"
}
// MissingCreateTemplateError reports CREATE execution without required template.
type MissingCreateTemplateError struct{}
func (e *MissingCreateTemplateError) Error() string {
return "create template is required for create execution"
}
var (
// ErrRuntimeMismatch is used with errors.Is for runtime mismatch failures.
ErrRuntimeMismatch = errors.New("runtime mismatch")
)

View file

@ -10,7 +10,7 @@ import (
)
func newTestExecutor() *Executor {
return NewExecutor(testSchema{}, func() string { return "alice" })
return NewExecutor(testSchema{}, func() string { return "alice" }, ExecutorRuntime{Mode: ExecutorRuntimeCLI})
}
func testDate(m time.Month, d int) time.Time {
@ -54,7 +54,7 @@ func TestExecuteNilStatement(t *testing.T) {
}
func TestNewExecutorNilUserFunc(t *testing.T) {
e := NewExecutor(testSchema{}, nil)
e := NewExecutor(testSchema{}, nil, ExecutorRuntime{Mode: ExecutorRuntimeCLI})
tasks := []*task.Task{
{ID: "T1", Title: "x", Status: "ready", Assignee: ""},
}
@ -880,19 +880,18 @@ func TestExecuteCreateWithTemplate(t *testing.T) {
e := newTestExecutor()
p := newTestParser()
// set template with tags=["idea"] and priority=7
e.SetTemplate(&task.Task{
template := &task.Task{
Tags: []string{"idea"},
Priority: 7,
Status: "ready",
Type: "story",
})
}
stmt, err := p.ParseStatement(`create title="x" tags=tags+["new"]`)
if err != nil {
t.Fatalf("parse: %v", err)
}
result, err := e.Execute(stmt, nil)
result, err := e.Execute(stmt, nil, ExecutionInput{CreateTemplate: template})
if err != nil {
t.Fatalf("execute: %v", err)
}
@ -910,7 +909,6 @@ func TestExecuteCreateWithTemplate(t *testing.T) {
func TestExecuteCreateWithoutTemplate(t *testing.T) {
e := newTestExecutor()
p := newTestParser()
// no SetTemplate call — template is nil
stmt, err := p.ParseStatement(`create title="x" priority=3`)
if err != nil {
@ -3409,7 +3407,7 @@ func TestExecuteUpdatePriorityOutOfRange(t *testing.T) {
if err == nil {
t.Fatal("expected error for out-of-range priority")
}
if !strings.Contains(err.Error(), "priority must be between") {
if !strings.Contains(err.Error(), "priority value out of range") {
t.Errorf("expected range error, got: %v", err)
}
})
@ -3462,8 +3460,8 @@ func TestExecuteUpdatePointsOutOfRange(t *testing.T) {
if err == nil {
t.Fatal("expected error for invalid points value")
}
if !strings.Contains(err.Error(), "invalid points") {
t.Errorf("expected 'invalid points' error, got: %v", err)
if !strings.Contains(err.Error(), "points value out of range") {
t.Errorf("expected points range error, got: %v", err)
}
})
}

View file

@ -71,7 +71,9 @@ func NewParser(schema Schema) *Parser {
}
}
// ParseStatement parses a CRUD statement and returns a validated AST.
// ParseStatement parses a CRUD statement and performs syntax, structural,
// built-in signature/arity, and type validation.
// Semantic runtime validation is a separate step.
func (p *Parser) ParseStatement(input string) (*Statement, error) {
g, err := p.stmtParser.ParseString("", input)
if err != nil {
@ -88,7 +90,9 @@ func (p *Parser) ParseStatement(input string) (*Statement, error) {
return stmt, nil
}
// ParseTrigger parses a reactive trigger rule and returns a validated AST.
// ParseTrigger parses a reactive trigger rule and performs syntax, structural,
// built-in signature/arity, and type validation.
// Semantic runtime validation is a separate step.
func (p *Parser) ParseTrigger(input string) (*Trigger, error) {
g, err := p.triggerParser.ParseString("", input)
if err != nil {
@ -105,7 +109,9 @@ func (p *Parser) ParseTrigger(input string) (*Trigger, error) {
return trig, nil
}
// ParseTimeTrigger parses a periodic time trigger and returns a validated AST.
// ParseTimeTrigger parses a periodic time trigger and performs syntax,
// structural, built-in signature/arity, and type validation.
// Semantic runtime validation is a separate step.
func (p *Parser) ParseTimeTrigger(input string) (*TimeTrigger, error) {
g, err := p.timeTriggerParser.ParseString("", input)
if err != nil {
@ -122,8 +128,9 @@ func (p *Parser) ParseTimeTrigger(input string) (*TimeTrigger, error) {
}
// ParseRule parses a trigger definition that is either an event trigger
// (before/after) or a time trigger (every). The grammar dispatches internally
// so the caller does not need to inspect the input string.
// (before/after) or a time trigger (every), and performs syntax, structural,
// built-in signature/arity, and type validation.
// Semantic runtime validation is a separate step after branching.
func (p *Parser) ParseRule(input string) (*Rule, error) {
g, err := p.ruleParser.ParseString("", input)
if err != nil {

166
ruki/runtime_safety_test.go Normal file
View file

@ -0,0 +1,166 @@
package ruki
import (
"errors"
"strings"
"testing"
)
func TestExecuteRawStatementRejectsCallBeforeEvaluation(t *testing.T) {
e := newTestExecutor()
p := newTestParser()
stmt, err := p.ParseStatement(`select where 1 = 2 and call("echo hello") = "x"`)
if err != nil {
t.Fatalf("parse: %v", err)
}
_, err = e.Execute(stmt, makeTasks())
if err == nil {
t.Fatal("expected semantic validation error")
}
if !strings.Contains(err.Error(), "call() is not supported yet") {
t.Fatalf("expected call() semantic validation error, got: %v", err)
}
}
func TestExecuteRawStatementRejectsIDOutsidePluginRuntime(t *testing.T) {
e := newTestExecutor()
p := newTestParser()
stmt, err := p.ParseStatement(`select where 1 = 2 and id() = "TIKI-000001"`)
if err != nil {
t.Fatalf("parse: %v", err)
}
_, err = e.Execute(stmt, makeTasks())
if err == nil {
t.Fatal("expected semantic validation error")
}
if !strings.Contains(err.Error(), "id() is only available in plugin runtime") {
t.Fatalf("expected id() runtime error, got: %v", err)
}
}
func TestExecuteValidatedStatementRuntimeMismatch(t *testing.T) {
e := newTestExecutor()
p := newTestParser()
validated, err := p.ParseAndValidateStatement(`select`, ExecutorRuntimePlugin)
if err != nil {
t.Fatalf("validate: %v", err)
}
_, err = e.Execute(validated, makeTasks())
if err == nil {
t.Fatal("expected runtime mismatch error")
}
var mismatch *RuntimeMismatchError
if !errors.As(err, &mismatch) {
t.Fatalf("expected RuntimeMismatchError, got: %v", err)
}
}
func TestExecuteUnsealedValidatedStatementRejected(t *testing.T) {
e := newTestExecutor()
unsealed := &ValidatedStatement{
statement: &Statement{Select: &SelectStmt{}},
}
_, err := e.Execute(unsealed, makeTasks())
if err == nil {
t.Fatal("expected unvalidated wrapper error")
}
var unvalidated *UnvalidatedWrapperError
if !errors.As(err, &unvalidated) {
t.Fatalf("expected UnvalidatedWrapperError, got: %v", err)
}
}
func TestExecuteValidatedCreateRequiresTemplate(t *testing.T) {
e := newTestExecutor()
p := newTestParser()
validated, err := p.ParseAndValidateStatement(`create title="x"`, ExecutorRuntimeCLI)
if err != nil {
t.Fatalf("validate: %v", err)
}
_, err = e.Execute(validated, nil)
if err == nil {
t.Fatal("expected missing create template error")
}
var missing *MissingCreateTemplateError
if !errors.As(err, &missing) {
t.Fatalf("expected MissingCreateTemplateError, got: %v", err)
}
}
func TestExecutePluginIDRequiresSelectedTaskID(t *testing.T) {
p := newTestParser()
e := NewExecutor(testSchema{}, func() string { return "alice" }, ExecutorRuntime{Mode: ExecutorRuntimePlugin})
validated, err := p.ParseAndValidateStatement(`select where id() = "TIKI-000001"`, ExecutorRuntimePlugin)
if err != nil {
t.Fatalf("validate: %v", err)
}
_, err = e.Execute(validated, makeTasks())
if err == nil {
t.Fatal("expected missing selected task id error")
}
var missing *MissingSelectedTaskIDError
if !errors.As(err, &missing) {
t.Fatalf("expected MissingSelectedTaskIDError, got: %v", err)
}
}
func TestValidatedTriggerCloneIsolated(t *testing.T) {
p := newTestParser()
validated, err := p.ParseAndValidateTrigger(`before create deny "blocked"`, ExecutorRuntimeEventTrigger)
if err != nil {
t.Fatalf("validate: %v", err)
}
clone := validated.TriggerClone()
if clone == nil {
t.Fatal("expected non-nil trigger clone")
}
clone.Timing = "after"
clone.Event = "delete"
clone.Deny = nil
after := validated.TriggerClone()
if after == nil {
t.Fatal("expected non-nil trigger clone after mutation")
}
if after.Timing != "before" || after.Event != "create" {
t.Fatalf("validated trigger was mutated: timing=%q event=%q", after.Timing, after.Event)
}
if after.Deny == nil || *after.Deny != "blocked" {
t.Fatalf("expected deny message to remain unchanged, got %#v", after.Deny)
}
}
func TestValidatedTimeTriggerCloneIsolated(t *testing.T) {
p := newTestParser()
validated, err := p.ParseAndValidateTimeTrigger(`every 2day create title="x"`, ExecutorRuntimeTimeTrigger)
if err != nil {
t.Fatalf("validate: %v", err)
}
clone := validated.TimeTriggerClone()
if clone == nil {
t.Fatal("expected non-nil time trigger clone")
}
clone.Interval = DurationLiteral{Value: 9, Unit: "week"}
clone.Action = nil
after := validated.TimeTriggerClone()
if after == nil {
t.Fatal("expected non-nil time trigger clone after mutation")
}
if after.Interval.Value != 2 || after.Interval.Unit != "day" {
t.Fatalf("validated time trigger interval was mutated: %+v", after.Interval)
}
if after.Action == nil || after.Action.Create == nil {
t.Fatal("expected action to remain unchanged")
}
}

715
ruki/semantic_validate.go Normal file
View file

@ -0,0 +1,715 @@
package ruki
import (
"fmt"
"github.com/boolean-maybe/tiki/task"
)
type validationSeal struct{}
var validatedSeal = &validationSeal{}
// UnvalidatedWrapperError is returned when a validated wrapper was not created
// by semantic validator constructors.
type UnvalidatedWrapperError struct {
Wrapper string
}
func (e *UnvalidatedWrapperError) Error() string {
return fmt.Sprintf("%s wrapper is not semantically validated", e.Wrapper)
}
// ValidatedStatement is an immutable, semantically validated statement wrapper.
type ValidatedStatement struct {
seal *validationSeal
runtime ExecutorRuntimeMode
usesIDFunc bool
statement *Statement
}
func (v *ValidatedStatement) RuntimeMode() ExecutorRuntimeMode { return v.runtime }
func (v *ValidatedStatement) UsesIDBuiltin() bool { return v.usesIDFunc }
func (v *ValidatedStatement) RequiresCreateTemplate() bool {
return v != nil && v.statement != nil && v.statement.Create != nil
}
func (v *ValidatedStatement) mustBeSealed() error {
if v == nil || v.seal != validatedSeal || v.statement == nil {
return &UnvalidatedWrapperError{Wrapper: "statement"}
}
return nil
}
// ValidatedTrigger is an immutable, semantically validated event-trigger wrapper.
type ValidatedTrigger struct {
seal *validationSeal
runtime ExecutorRuntimeMode
usesIDFunc bool
trigger *Trigger
}
func (v *ValidatedTrigger) RuntimeMode() ExecutorRuntimeMode { return v.runtime }
func (v *ValidatedTrigger) UsesIDBuiltin() bool { return v.usesIDFunc }
func (v *ValidatedTrigger) Timing() string {
if v == nil || v.trigger == nil {
return ""
}
return v.trigger.Timing
}
func (v *ValidatedTrigger) Event() string {
if v == nil || v.trigger == nil {
return ""
}
return v.trigger.Event
}
func (v *ValidatedTrigger) HasRunAction() bool {
return v != nil && v.trigger != nil && v.trigger.Run != nil
}
func (v *ValidatedTrigger) DenyMessage() (string, bool) {
if v == nil || v.trigger == nil || v.trigger.Deny == nil {
return "", false
}
return *v.trigger.Deny, true
}
func (v *ValidatedTrigger) RequiresCreateTemplate() bool {
return v != nil && v.trigger != nil && v.trigger.Action != nil && v.trigger.Action.Create != nil
}
func (v *ValidatedTrigger) TriggerClone() *Trigger {
if v == nil {
return nil
}
return cloneTrigger(v.trigger)
}
func (v *ValidatedTrigger) mustBeSealed() error {
if v == nil || v.seal != validatedSeal || v.trigger == nil {
return &UnvalidatedWrapperError{Wrapper: "trigger"}
}
return nil
}
// ValidatedTimeTrigger is an immutable, semantically validated time-trigger wrapper.
type ValidatedTimeTrigger struct {
seal *validationSeal
runtime ExecutorRuntimeMode
usesIDFunc bool
timeTrigger *TimeTrigger
}
func (v *ValidatedTimeTrigger) RuntimeMode() ExecutorRuntimeMode { return v.runtime }
func (v *ValidatedTimeTrigger) UsesIDBuiltin() bool { return v.usesIDFunc }
func (v *ValidatedTimeTrigger) IntervalLiteral() DurationLiteral {
if v == nil || v.timeTrigger == nil {
return DurationLiteral{}
}
return v.timeTrigger.Interval
}
func (v *ValidatedTimeTrigger) RequiresCreateTemplate() bool {
return v != nil && v.timeTrigger != nil && v.timeTrigger.Action != nil && v.timeTrigger.Action.Create != nil
}
func (v *ValidatedTimeTrigger) TimeTriggerClone() *TimeTrigger {
if v == nil {
return nil
}
return cloneTimeTrigger(v.timeTrigger)
}
func (v *ValidatedTimeTrigger) mustBeSealed() error {
if v == nil || v.seal != validatedSeal || v.timeTrigger == nil {
return &UnvalidatedWrapperError{Wrapper: "time trigger"}
}
return nil
}
// ValidatedRule is a discriminated union for ParseAndValidateRule.
type ValidatedRule interface {
isValidatedRule()
RuntimeMode() ExecutorRuntimeMode
}
// ValidatedEventRule wraps a validated event trigger.
type ValidatedEventRule struct {
seal *validationSeal
trigger *ValidatedTrigger
}
func (ValidatedEventRule) isValidatedRule() {}
func (r ValidatedEventRule) RuntimeMode() ExecutorRuntimeMode {
if r.trigger == nil {
return ""
}
return r.trigger.RuntimeMode()
}
func (r ValidatedEventRule) Trigger() *ValidatedTrigger { return r.trigger }
// ValidatedTimeRule wraps a validated time trigger.
type ValidatedTimeRule struct {
seal *validationSeal
time *ValidatedTimeTrigger
}
func (ValidatedTimeRule) isValidatedRule() {}
func (r ValidatedTimeRule) RuntimeMode() ExecutorRuntimeMode {
if r.time == nil {
return ""
}
return r.time.RuntimeMode()
}
func (r ValidatedTimeRule) TimeTrigger() *ValidatedTimeTrigger { return r.time }
// SemanticValidator performs runtime-aware semantic validation after parse/type validation.
type SemanticValidator struct {
runtime ExecutorRuntimeMode
}
// NewSemanticValidator creates a semantic validator for a specific runtime mode.
func NewSemanticValidator(runtime ExecutorRuntimeMode) *SemanticValidator {
if runtime == "" {
runtime = ExecutorRuntimeCLI
}
return &SemanticValidator{runtime: runtime}
}
// ParseAndValidateStatement parses a statement and applies runtime-aware semantic validation.
func (p *Parser) ParseAndValidateStatement(input string, runtime ExecutorRuntimeMode) (*ValidatedStatement, error) {
stmt, err := p.ParseStatement(input)
if err != nil {
return nil, err
}
return NewSemanticValidator(runtime).ValidateStatement(stmt)
}
// ParseAndValidateTrigger parses an event trigger and applies runtime-aware semantic validation.
func (p *Parser) ParseAndValidateTrigger(input string, runtime ExecutorRuntimeMode) (*ValidatedTrigger, error) {
trig, err := p.ParseTrigger(input)
if err != nil {
return nil, err
}
return NewSemanticValidator(runtime).ValidateTrigger(trig)
}
// ParseAndValidateTimeTrigger parses a time trigger and applies runtime-aware semantic validation.
func (p *Parser) ParseAndValidateTimeTrigger(input string, runtime ExecutorRuntimeMode) (*ValidatedTimeTrigger, error) {
tt, err := p.ParseTimeTrigger(input)
if err != nil {
return nil, err
}
return NewSemanticValidator(runtime).ValidateTimeTrigger(tt)
}
// ParseAndValidateRule parses a trigger rule union and applies the correct semantic runtime
// validation branch (event trigger vs time trigger).
func (p *Parser) ParseAndValidateRule(input string) (ValidatedRule, error) {
rule, err := p.ParseRule(input)
if err != nil {
return nil, err
}
switch {
case rule == nil:
return nil, fmt.Errorf("empty rule")
case rule.Trigger != nil:
vt, err := NewSemanticValidator(ExecutorRuntimeEventTrigger).ValidateTrigger(rule.Trigger)
if err != nil {
return nil, err
}
return ValidatedEventRule{seal: validatedSeal, trigger: vt}, nil
case rule.TimeTrigger != nil:
vt, err := NewSemanticValidator(ExecutorRuntimeTimeTrigger).ValidateTimeTrigger(rule.TimeTrigger)
if err != nil {
return nil, err
}
return ValidatedTimeRule{seal: validatedSeal, time: vt}, nil
default:
return nil, fmt.Errorf("empty rule")
}
}
// ValidateStatement applies runtime-aware semantic checks to a parsed statement.
func (v *SemanticValidator) ValidateStatement(stmt *Statement) (*ValidatedStatement, error) {
if stmt == nil {
return nil, fmt.Errorf("nil statement")
}
usesID, hasCall, err := scanStatementSemantics(stmt)
if err != nil {
return nil, err
}
if hasCall {
return nil, fmt.Errorf("call() is not supported yet")
}
if usesID && v.runtime != ExecutorRuntimePlugin {
return nil, fmt.Errorf("id() is only available in plugin runtime")
}
if err := validateStatementAssignmentsSemantics(stmt); err != nil {
return nil, err
}
return &ValidatedStatement{
seal: validatedSeal,
runtime: v.runtime,
usesIDFunc: usesID,
statement: cloneStatement(stmt),
}, nil
}
// ValidateTrigger applies runtime-aware semantic checks to a parsed event trigger.
func (v *SemanticValidator) ValidateTrigger(trig *Trigger) (*ValidatedTrigger, error) {
if trig == nil {
return nil, fmt.Errorf("nil trigger")
}
usesID, hasCall, err := scanTriggerSemantics(trig)
if err != nil {
return nil, err
}
if hasCall {
return nil, fmt.Errorf("call() is not supported yet")
}
if usesID && v.runtime != ExecutorRuntimePlugin {
return nil, fmt.Errorf("id() is only available in plugin runtime")
}
if trig.Action != nil {
if err := validateStatementAssignmentsSemantics(trig.Action); err != nil {
return nil, err
}
}
return &ValidatedTrigger{
seal: validatedSeal,
runtime: v.runtime,
usesIDFunc: usesID,
trigger: cloneTrigger(trig),
}, nil
}
// ValidateTimeTrigger applies runtime-aware semantic checks to a parsed time trigger.
func (v *SemanticValidator) ValidateTimeTrigger(tt *TimeTrigger) (*ValidatedTimeTrigger, error) {
if tt == nil {
return nil, fmt.Errorf("nil time trigger")
}
usesID, hasCall, err := scanTimeTriggerSemantics(tt)
if err != nil {
return nil, err
}
if hasCall {
return nil, fmt.Errorf("call() is not supported yet")
}
if usesID && v.runtime != ExecutorRuntimePlugin {
return nil, fmt.Errorf("id() is only available in plugin runtime")
}
if tt.Action != nil {
if err := validateStatementAssignmentsSemantics(tt.Action); err != nil {
return nil, err
}
}
return &ValidatedTimeTrigger{
seal: validatedSeal,
runtime: v.runtime,
usesIDFunc: usesID,
timeTrigger: cloneTimeTrigger(tt),
}, nil
}
func validateStatementAssignmentsSemantics(stmt *Statement) error {
switch {
case stmt.Create != nil:
return validateAssignmentsSemantics(stmt.Create.Assignments)
case stmt.Update != nil:
return validateAssignmentsSemantics(stmt.Update.Set)
default:
return nil
}
}
func validateAssignmentsSemantics(assignments []Assignment) error {
for _, a := range assignments {
switch a.Field {
case "id", "createdBy", "createdAt", "updatedAt":
return fmt.Errorf("field %q is immutable", a.Field)
}
switch a.Field {
case "title", "status", "type", "priority":
if _, ok := a.Value.(*EmptyLiteral); ok {
return fmt.Errorf("field %q cannot be empty", a.Field)
}
}
switch a.Field {
case "priority":
if lit, ok := a.Value.(*IntLiteral); ok && !task.IsValidPriority(lit.Value) {
return fmt.Errorf("priority value out of range: %d", lit.Value)
}
case "points":
if lit, ok := a.Value.(*IntLiteral); ok && !task.IsValidPoints(lit.Value) {
return fmt.Errorf("points value out of range: %d", lit.Value)
}
}
}
return nil
}
func scanStatementSemantics(stmt *Statement) (usesID bool, hasCall bool, err error) {
switch {
case stmt.Select != nil:
return scanSelectSemantics(stmt.Select)
case stmt.Create != nil:
return scanAssignmentsSemantics(stmt.Create.Assignments)
case stmt.Update != nil:
u1, c1, err := scanConditionSemantics(stmt.Update.Where)
if err != nil {
return false, false, err
}
u2, c2, err := scanAssignmentsSemantics(stmt.Update.Set)
if err != nil {
return false, false, err
}
return u1 || u2, c1 || c2, nil
case stmt.Delete != nil:
return scanConditionSemantics(stmt.Delete.Where)
default:
return false, false, fmt.Errorf("empty statement")
}
}
func scanSelectSemantics(sel *SelectStmt) (usesID bool, hasCall bool, err error) {
if sel == nil {
return false, false, nil
}
return scanConditionSemantics(sel.Where)
}
func scanTriggerSemantics(trig *Trigger) (usesID bool, hasCall bool, err error) {
var u, c bool
if trig.Where != nil {
uu, cc, err := scanConditionSemantics(trig.Where)
if err != nil {
return false, false, err
}
u, c = u || uu, c || cc
}
if trig.Action != nil {
uu, cc, err := scanStatementSemantics(trig.Action)
if err != nil {
return false, false, err
}
u, c = u || uu, c || cc
}
if trig.Run != nil {
uu, cc, err := scanExprSemantics(trig.Run.Command)
if err != nil {
return false, false, err
}
u, c = u || uu, c || cc
}
return u, c, nil
}
func scanTimeTriggerSemantics(tt *TimeTrigger) (usesID bool, hasCall bool, err error) {
if tt == nil || tt.Action == nil {
return false, false, nil
}
return scanStatementSemantics(tt.Action)
}
func scanAssignmentsSemantics(assignments []Assignment) (usesID bool, hasCall bool, err error) {
for _, a := range assignments {
u, c, err := scanExprSemantics(a.Value)
if err != nil {
return false, false, err
}
usesID = usesID || u
hasCall = hasCall || c
}
return usesID, hasCall, nil
}
func scanConditionSemantics(cond Condition) (usesID bool, hasCall bool, err error) {
if cond == nil {
return false, false, nil
}
switch c := cond.(type) {
case *BinaryCondition:
u1, c1, err := scanConditionSemantics(c.Left)
if err != nil {
return false, false, err
}
u2, c2, err := scanConditionSemantics(c.Right)
if err != nil {
return false, false, err
}
return u1 || u2, c1 || c2, nil
case *NotCondition:
return scanConditionSemantics(c.Inner)
case *CompareExpr:
u1, c1, err := scanExprSemantics(c.Left)
if err != nil {
return false, false, err
}
u2, c2, err := scanExprSemantics(c.Right)
if err != nil {
return false, false, err
}
return u1 || u2, c1 || c2, nil
case *IsEmptyExpr:
return scanExprSemantics(c.Expr)
case *InExpr:
u1, c1, err := scanExprSemantics(c.Value)
if err != nil {
return false, false, err
}
u2, c2, err := scanExprSemantics(c.Collection)
if err != nil {
return false, false, err
}
return u1 || u2, c1 || c2, nil
case *QuantifierExpr:
u1, c1, err := scanExprSemantics(c.Expr)
if err != nil {
return false, false, err
}
u2, c2, err := scanConditionSemantics(c.Condition)
if err != nil {
return false, false, err
}
return u1 || u2, c1 || c2, nil
default:
return false, false, fmt.Errorf("unknown condition type %T", c)
}
}
func scanExprSemantics(expr Expr) (usesID bool, hasCall bool, err error) {
if expr == nil {
return false, false, nil
}
switch e := expr.(type) {
case *FunctionCall:
if e.Name == "id" {
usesID = true
}
if e.Name == "call" {
hasCall = true
}
for _, arg := range e.Args {
u, c, err := scanExprSemantics(arg)
if err != nil {
return false, false, err
}
usesID = usesID || u
hasCall = hasCall || c
}
return usesID, hasCall, nil
case *BinaryExpr:
u1, c1, err := scanExprSemantics(e.Left)
if err != nil {
return false, false, err
}
u2, c2, err := scanExprSemantics(e.Right)
if err != nil {
return false, false, err
}
return u1 || u2, c1 || c2, nil
case *ListLiteral:
for _, elem := range e.Elements {
u, c, err := scanExprSemantics(elem)
if err != nil {
return false, false, err
}
usesID = usesID || u
hasCall = hasCall || c
}
return usesID, hasCall, nil
case *SubQuery:
return scanConditionSemantics(e.Where)
default:
return false, false, nil
}
}
func cloneStatement(stmt *Statement) *Statement {
if stmt == nil {
return nil
}
out := &Statement{}
if stmt.Select != nil {
out.Select = cloneSelect(stmt.Select)
}
if stmt.Create != nil {
out.Create = cloneCreate(stmt.Create)
}
if stmt.Update != nil {
out.Update = cloneUpdate(stmt.Update)
}
if stmt.Delete != nil {
out.Delete = cloneDelete(stmt.Delete)
}
return out
}
func cloneSelect(sel *SelectStmt) *SelectStmt {
if sel == nil {
return nil
}
var fields []string
if sel.Fields != nil {
fields = append([]string(nil), sel.Fields...)
}
var orderBy []OrderByClause
if sel.OrderBy != nil {
orderBy = append([]OrderByClause(nil), sel.OrderBy...)
}
return &SelectStmt{
Fields: fields,
Where: cloneCondition(sel.Where),
OrderBy: orderBy,
}
}
func cloneCreate(cr *CreateStmt) *CreateStmt {
if cr == nil {
return nil
}
out := &CreateStmt{Assignments: make([]Assignment, len(cr.Assignments))}
for i, a := range cr.Assignments {
out.Assignments[i] = cloneAssignment(a)
}
return out
}
func cloneUpdate(up *UpdateStmt) *UpdateStmt {
if up == nil {
return nil
}
out := &UpdateStmt{
Where: cloneCondition(up.Where),
Set: make([]Assignment, len(up.Set)),
}
for i, a := range up.Set {
out.Set[i] = cloneAssignment(a)
}
return out
}
func cloneDelete(del *DeleteStmt) *DeleteStmt {
if del == nil {
return nil
}
return &DeleteStmt{Where: cloneCondition(del.Where)}
}
func cloneTrigger(trig *Trigger) *Trigger {
if trig == nil {
return nil
}
out := &Trigger{
Timing: trig.Timing,
Event: trig.Event,
Where: cloneCondition(trig.Where),
Action: cloneStatement(trig.Action),
}
if trig.Run != nil {
out.Run = &RunAction{Command: cloneExpr(trig.Run.Command)}
}
if trig.Deny != nil {
s := *trig.Deny
out.Deny = &s
}
return out
}
func cloneTimeTrigger(tt *TimeTrigger) *TimeTrigger {
if tt == nil {
return nil
}
return &TimeTrigger{
Interval: tt.Interval,
Action: cloneStatement(tt.Action),
}
}
func cloneAssignment(a Assignment) Assignment {
return Assignment{
Field: a.Field,
Value: cloneExpr(a.Value),
}
}
func cloneCondition(cond Condition) Condition {
if cond == nil {
return nil
}
switch c := cond.(type) {
case *BinaryCondition:
return &BinaryCondition{
Op: c.Op,
Left: cloneCondition(c.Left),
Right: cloneCondition(c.Right),
}
case *NotCondition:
return &NotCondition{Inner: cloneCondition(c.Inner)}
case *CompareExpr:
return &CompareExpr{
Left: cloneExpr(c.Left),
Op: c.Op,
Right: cloneExpr(c.Right),
}
case *IsEmptyExpr:
return &IsEmptyExpr{
Expr: cloneExpr(c.Expr),
Negated: c.Negated,
}
case *InExpr:
return &InExpr{
Value: cloneExpr(c.Value),
Collection: cloneExpr(c.Collection),
Negated: c.Negated,
}
case *QuantifierExpr:
return &QuantifierExpr{
Expr: cloneExpr(c.Expr),
Kind: c.Kind,
Condition: cloneCondition(c.Condition),
}
default:
return nil
}
}
func cloneExpr(expr Expr) Expr {
if expr == nil {
return nil
}
switch e := expr.(type) {
case *FieldRef:
return &FieldRef{Name: e.Name}
case *QualifiedRef:
return &QualifiedRef{Qualifier: e.Qualifier, Name: e.Name}
case *StringLiteral:
return &StringLiteral{Value: e.Value}
case *IntLiteral:
return &IntLiteral{Value: e.Value}
case *DateLiteral:
return &DateLiteral{Value: e.Value}
case *DurationLiteral:
return &DurationLiteral{Value: e.Value, Unit: e.Unit}
case *ListLiteral:
elems := make([]Expr, len(e.Elements))
for i, elem := range e.Elements {
elems[i] = cloneExpr(elem)
}
return &ListLiteral{Elements: elems}
case *EmptyLiteral:
return &EmptyLiteral{}
case *FunctionCall:
args := make([]Expr, len(e.Args))
for i, arg := range e.Args {
args[i] = cloneExpr(arg)
}
return &FunctionCall{Name: e.Name, Args: args}
case *BinaryExpr:
return &BinaryExpr{
Op: e.Op,
Left: cloneExpr(e.Left),
Right: cloneExpr(e.Right),
}
case *SubQuery:
return &SubQuery{Where: cloneCondition(e.Where)}
default:
return nil
}
}

View file

@ -30,50 +30,123 @@ type TriggerContext struct {
AllTasks []*task.Task
}
// EvalGuard evaluates a trigger's Where condition against the triggering event.
// EvalGuard evaluates a trigger's where condition against the triggering event.
// Returns true if the trigger should fire (guard passes or no guard).
func (te *TriggerExecutor) EvalGuard(trig *Trigger, tc *TriggerContext) (bool, error) {
if trig.Where == nil {
func (te *TriggerExecutor) EvalGuard(trig any, tc *TriggerContext) (bool, error) {
validated, err := validateEventTriggerInput(trig)
if err != nil {
return false, err
}
where := validated.trigger.Where
if where == nil {
return true, nil
}
// the guard evaluates qualified refs against old/new directly;
// there is no "current task" — we use a sentinel that QualifiedRef overrides
sentinel := te.guardSentinel(tc)
exec := te.newExecWithOverrides(tc)
return exec.evalCondition(trig.Where, sentinel, tc.AllTasks)
return exec.evalCondition(where, sentinel, tc.AllTasks)
}
// ExecTimeTriggerAction executes a time trigger's action against all tasks.
// Uses a plain Executor (no old/new overrides) since time triggers have no
// mutation context — the parser forbids qualified refs in them.
func (te *TriggerExecutor) ExecTimeTriggerAction(tt *TimeTrigger, allTasks []*task.Task) (*Result, error) {
if tt.Action == nil {
return nil, fmt.Errorf("time trigger has no action")
func (te *TriggerExecutor) ExecTimeTriggerAction(tt any, allTasks []*task.Task, inputs ...ExecutionInput) (*Result, error) {
var input ExecutionInput
if len(inputs) > 0 {
input = inputs[0]
}
exec := NewExecutor(te.schema, te.userFunc, ExecutorRuntime{Mode: ExecutorRuntimeTimeTrigger})
switch t := tt.(type) {
case *ValidatedTimeTrigger:
if err := t.mustBeSealed(); err != nil {
return nil, err
}
if t.runtime != ExecutorRuntimeTimeTrigger {
return nil, &RuntimeMismatchError{
ValidatedFor: t.runtime,
Runtime: ExecutorRuntimeTimeTrigger,
}
}
if t.timeTrigger.Action == nil {
return nil, fmt.Errorf("time trigger has no action")
}
action := &ValidatedStatement{
seal: validatedSeal,
runtime: ExecutorRuntimeTimeTrigger,
usesIDFunc: t.usesIDFunc,
statement: cloneStatement(t.timeTrigger.Action),
}
return exec.Execute(action, allTasks, input)
case *TimeTrigger:
if t.Action == nil {
return nil, fmt.Errorf("time trigger has no action")
}
return exec.Execute(t.Action, allTasks, input)
default:
return nil, fmt.Errorf("unsupported time trigger type %T", tt)
}
exec := NewExecutor(te.schema, te.userFunc)
return exec.Execute(tt.Action, allTasks)
}
// ExecAction executes a trigger's CRUD action statement and returns the result.
// QualifiedRefs resolve against tc.Old/tc.New. Bare fields resolve against target tasks.
// Returns *Result for persistence by service/.
func (te *TriggerExecutor) ExecAction(trig *Trigger, tc *TriggerContext) (*Result, error) {
if trig.Action == nil {
return nil, fmt.Errorf("trigger has no action")
func (te *TriggerExecutor) ExecAction(trig any, tc *TriggerContext, inputs ...ExecutionInput) (*Result, error) {
var input ExecutionInput
if len(inputs) > 0 {
input = inputs[0]
}
exec := te.newExecWithOverrides(tc)
return exec.Execute(trig.Action, tc.AllTasks)
switch t := trig.(type) {
case *ValidatedTrigger:
if err := t.mustBeSealed(); err != nil {
return nil, err
}
if t.runtime != ExecutorRuntimeEventTrigger {
return nil, &RuntimeMismatchError{
ValidatedFor: t.runtime,
Runtime: ExecutorRuntimeEventTrigger,
}
}
if t.trigger.Action == nil {
return nil, fmt.Errorf("trigger has no action")
}
action := &ValidatedStatement{
seal: validatedSeal,
runtime: ExecutorRuntimeEventTrigger,
usesIDFunc: t.usesIDFunc,
statement: cloneStatement(t.trigger.Action),
}
return exec.Execute(action, tc.AllTasks, input)
case *Trigger:
if t.Action == nil {
return nil, fmt.Errorf("trigger has no action")
}
return exec.Execute(t.Action, tc.AllTasks, input)
default:
return nil, fmt.Errorf("unsupported trigger type %T", trig)
}
}
// ExecRun evaluates the run() command expression to a string against the trigger context.
// Returns the command string for execution by service/.
func (te *TriggerExecutor) ExecRun(trig *Trigger, tc *TriggerContext) (string, error) {
if trig.Run == nil {
func (te *TriggerExecutor) ExecRun(trig any, tc *TriggerContext) (string, error) {
validated, err := validateEventTriggerInput(trig)
if err != nil {
return "", err
}
if validated.trigger.Run == nil {
return "", fmt.Errorf("trigger has no run action")
}
command := validated.trigger.Run.Command
if command == nil {
return "", fmt.Errorf("trigger has no run action")
}
sentinel := te.guardSentinel(tc)
exec := te.newExecWithOverrides(tc)
val, err := exec.evalExpr(trig.Run.Command, sentinel, tc.AllTasks)
val, err := exec.evalExpr(command, sentinel, tc.AllTasks)
if err != nil {
return "", fmt.Errorf("evaluating run command: %w", err)
}
@ -97,6 +170,26 @@ func (te *TriggerExecutor) guardSentinel(tc *TriggerContext) *task.Task {
return &task.Task{}
}
func validateEventTriggerInput(trig any) (*ValidatedTrigger, error) {
switch t := trig.(type) {
case *ValidatedTrigger:
if err := t.mustBeSealed(); err != nil {
return nil, err
}
if t.runtime != ExecutorRuntimeEventTrigger {
return nil, &RuntimeMismatchError{
ValidatedFor: t.runtime,
Runtime: ExecutorRuntimeEventTrigger,
}
}
return t, nil
case *Trigger:
return NewSemanticValidator(ExecutorRuntimeEventTrigger).ValidateTrigger(t)
default:
return nil, fmt.Errorf("unsupported trigger type %T", trig)
}
}
// triggerExecOverride wraps Executor and intercepts QualifiedRef evaluation.
type triggerExecOverride struct {
*Executor
@ -106,7 +199,7 @@ type triggerExecOverride struct {
// newExecWithOverrides creates a fresh Executor with QualifiedRef interception.
func (te *TriggerExecutor) newExecWithOverrides(tc *TriggerContext) *triggerExecOverride {
return &triggerExecOverride{
Executor: NewExecutor(te.schema, te.userFunc),
Executor: NewExecutor(te.schema, te.userFunc, ExecutorRuntime{Mode: ExecutorRuntimeEventTrigger}),
tc: tc,
}
}
@ -377,17 +470,61 @@ func (e *triggerExecOverride) evalNextDateOverride(fc *FunctionCall, t *task.Tas
}
// Execute overrides the base Executor to use our evalExpr/evalCondition.
func (e *triggerExecOverride) Execute(stmt *Statement, tasks []*task.Task) (*Result, error) {
func (e *triggerExecOverride) Execute(stmt any, tasks []*task.Task, inputs ...ExecutionInput) (*Result, error) {
var input ExecutionInput
if len(inputs) > 0 {
input = inputs[0]
}
if stmt == nil {
return nil, fmt.Errorf("nil statement")
}
var validated *ValidatedStatement
var rawStmt *Statement
rawInput := false
requiresCreateTemplate := false
switch s := stmt.(type) {
case *ValidatedStatement:
validated = s
requiresCreateTemplate = true
case *Statement:
rawInput = true
var err error
validated, err = NewSemanticValidator(e.runtime.Mode).ValidateStatement(s)
if err != nil {
return nil, err
}
default:
return nil, fmt.Errorf("unsupported statement type %T", stmt)
}
if validated != nil {
if err := validated.mustBeSealed(); err != nil {
return nil, err
}
if validated.runtime != e.runtime.Mode {
return nil, &RuntimeMismatchError{
ValidatedFor: validated.runtime,
Runtime: e.runtime.Mode,
}
}
if validated.usesIDFunc && e.runtime.Mode == ExecutorRuntimePlugin && strings.TrimSpace(input.SelectedTaskID) == "" {
return nil, &MissingSelectedTaskIDError{}
}
rawStmt = validated.statement
if rawInput {
requiresCreateTemplate = false
}
}
e.currentInput = input
defer func() { e.currentInput = ExecutionInput{} }()
switch {
case stmt.Create != nil:
return e.executeCreate(stmt.Create, tasks)
case stmt.Update != nil:
return e.executeUpdate(stmt.Update, tasks)
case stmt.Delete != nil:
return e.executeDelete(stmt.Delete, tasks)
case rawStmt.Create != nil:
return e.executeCreate(rawStmt.Create, tasks, requiresCreateTemplate)
case rawStmt.Update != nil:
return e.executeUpdate(rawStmt.Update, tasks)
case rawStmt.Delete != nil:
return e.executeDelete(rawStmt.Delete, tasks)
default:
return nil, fmt.Errorf("unsupported trigger action type")
}
@ -419,8 +556,16 @@ func (e *triggerExecOverride) executeUpdate(upd *UpdateStmt, tasks []*task.Task)
return &Result{Update: &UpdateResult{Updated: clones}}, nil
}
func (e *triggerExecOverride) executeCreate(cr *CreateStmt, tasks []*task.Task) (*Result, error) {
t := &task.Task{}
func (e *triggerExecOverride) executeCreate(cr *CreateStmt, tasks []*task.Task, requireTemplate bool) (*Result, error) {
if requireTemplate && e.currentInput.CreateTemplate == nil {
return nil, &MissingCreateTemplateError{}
}
var t *task.Task
if e.currentInput.CreateTemplate != nil {
t = e.currentInput.CreateTemplate.Clone()
} else {
t = &task.Task{}
}
for _, a := range cr.Assignments {
val, err := e.evalExpr(a.Value, t, tasks)
if err != nil {

View file

@ -1,6 +1,7 @@
package ruki
import (
"errors"
"strings"
"testing"
"time"
@ -938,8 +939,8 @@ func TestExecute_UnsupportedType(t *testing.T) {
if err == nil {
t.Fatal("expected error for unsupported trigger action type")
}
if !strings.Contains(err.Error(), "unsupported trigger action type") {
t.Fatalf("expected 'unsupported trigger action type' error, got: %v", err)
if !strings.Contains(err.Error(), "empty statement") {
t.Fatalf("expected 'empty statement' error, got: %v", err)
}
}
@ -1016,6 +1017,92 @@ func TestExecRun_NonStringResult(t *testing.T) {
}
}
func TestEvalGuardRawTriggerRejectsCallBeforeEvaluation(t *testing.T) {
te := newTestTriggerExecutor()
p := newTestParser()
trig, err := p.ParseTrigger(`before update where 1 = 2 and call("echo hello") = "x" deny "blocked"`)
if err != nil {
t.Fatalf("parse: %v", err)
}
tc := &TriggerContext{
Old: &task.Task{ID: "TIKI-000001", Status: "ready"},
New: &task.Task{ID: "TIKI-000001", Status: "done"},
}
_, err = te.EvalGuard(trig, tc)
if err == nil {
t.Fatal("expected semantic validation error")
}
if !strings.Contains(err.Error(), "call() is not supported yet") {
t.Fatalf("expected call() semantic validation error, got: %v", err)
}
}
func TestExecRunRawTriggerRejectsCall(t *testing.T) {
te := newTestTriggerExecutor()
p := newTestParser()
trig, err := p.ParseTrigger(`after update run(call("echo hello"))`)
if err != nil {
t.Fatalf("parse: %v", err)
}
tc := &TriggerContext{
Old: &task.Task{ID: "TIKI-000001"},
New: &task.Task{ID: "TIKI-000001"},
}
_, err = te.ExecRun(trig, tc)
if err == nil {
t.Fatal("expected semantic validation error")
}
if !strings.Contains(err.Error(), "call() is not supported yet") {
t.Fatalf("expected call() semantic validation error, got: %v", err)
}
}
func TestEvalGuardValidatedTriggerRuntimeMismatch(t *testing.T) {
te := newTestTriggerExecutor()
p := newTestParser()
validated, err := p.ParseAndValidateTrigger(`before update where new.status = "done" deny "blocked"`, ExecutorRuntimePlugin)
if err != nil {
t.Fatalf("validate: %v", err)
}
tc := &TriggerContext{
Old: &task.Task{ID: "TIKI-000001", Status: "ready"},
New: &task.Task{ID: "TIKI-000001", Status: "done"},
}
_, err = te.EvalGuard(validated, tc)
if err == nil {
t.Fatal("expected runtime mismatch error")
}
var mismatch *RuntimeMismatchError
if !errors.As(err, &mismatch) {
t.Fatalf("expected RuntimeMismatchError, got: %v", err)
}
}
func TestExecTimeTriggerActionValidatedRuntimeMismatch(t *testing.T) {
te := newTestTriggerExecutor()
p := newTestParser()
validated, err := p.ParseAndValidateTimeTrigger(`every 1day delete where status = "done"`, ExecutorRuntimePlugin)
if err != nil {
t.Fatalf("validate: %v", err)
}
_, err = te.ExecTimeTriggerAction(validated, nil)
if err == nil {
t.Fatal("expected runtime mismatch error")
}
var mismatch *RuntimeMismatchError
if !errors.As(err, &mismatch) {
t.Fatalf("expected RuntimeMismatchError, got: %v", err)
}
}
// --- coverage gap tests ---
func TestNewTriggerExecutor_NilUserFunc(t *testing.T) {

View file

@ -31,6 +31,7 @@ var builtinFuncs = map[string]struct {
maxArgs int
}{
"count": {ValueInt, 1, 1},
"id": {ValueID, 0, 0},
"now": {ValueTimestamp, 0, 0},
"next_date": {ValueDate, 1, 1},
"blocks": {ValueListRef, 1, 1},

View file

@ -25,12 +25,14 @@ const runCommandTimeout = 30 * time.Second
type triggerEntry struct {
description string
trigger *ruki.Trigger
validated *ruki.ValidatedTrigger
}
// TimeTriggerEntry holds a parsed time trigger and its description.
type TimeTriggerEntry struct {
Description string
Trigger *ruki.TimeTrigger
Validated *ruki.ValidatedTimeTrigger
}
// TriggerEngine bridges parsed triggers with the mutation gate.
@ -57,20 +59,28 @@ func NewTriggerEngine(triggers []triggerEntry, timeTriggers []TimeTriggerEntry,
}
func (te *TriggerEngine) addTrigger(entry triggerEntry) {
trig := entry.trigger
timing, event, ok := triggerTimingEvent(entry)
if !ok {
slog.Warn("skipping trigger with missing timing/event metadata",
"trigger", entry.description)
return
}
switch {
case trig.Timing == "before" && trig.Event == "create":
case timing == "before" && event == "create":
te.beforeCreate = append(te.beforeCreate, entry)
case trig.Timing == "before" && trig.Event == "update":
case timing == "before" && event == "update":
te.beforeUpdate = append(te.beforeUpdate, entry)
case trig.Timing == "before" && trig.Event == "delete":
case timing == "before" && event == "delete":
te.beforeDelete = append(te.beforeDelete, entry)
case trig.Timing == "after" && trig.Event == "create":
case timing == "after" && event == "create":
te.afterCreate = append(te.afterCreate, entry)
case trig.Timing == "after" && trig.Event == "update":
case timing == "after" && event == "update":
te.afterUpdate = append(te.afterUpdate, entry)
case trig.Timing == "after" && trig.Event == "delete":
case timing == "after" && event == "delete":
te.afterDelete = append(te.afterDelete, entry)
default:
slog.Warn("skipping trigger with unsupported timing/event",
"trigger", entry.description, "timing", timing, "event", event)
}
}
@ -111,14 +121,17 @@ func (te *TriggerEngine) RegisterWithGate(gate *TaskMutationGate) {
func (te *TriggerEngine) makeBeforeValidator(entry triggerEntry) MutationValidator {
return func(old, new *task.Task, allTasks []*task.Task) *Rejection {
tc := &ruki.TriggerContext{Old: old, New: new, AllTasks: allTasks}
match, err := te.executor.EvalGuard(entry.trigger, tc)
match, err := te.executor.EvalGuard(eventTriggerForExec(entry), tc)
if err != nil {
return &Rejection{
Reason: fmt.Sprintf("trigger %q guard evaluation failed: %v", entry.description, err),
}
}
if match {
return &Rejection{Reason: *entry.trigger.Deny}
if msg, ok := triggerDenyMessage(eventTriggerForExec(entry)); ok {
return &Rejection{Reason: msg}
}
return &Rejection{Reason: "trigger rejected"}
}
return nil
}
@ -138,7 +151,7 @@ func (te *TriggerEngine) makeAfterHook(entry triggerEntry) AfterHook {
allTasks := te.gate.ReadStore().GetAllTasks()
tc := &ruki.TriggerContext{Old: old, New: new, AllTasks: allTasks}
match, err := te.executor.EvalGuard(entry.trigger, tc)
match, err := te.executor.EvalGuard(eventTriggerForExec(entry), tc)
if err != nil {
slog.Error("after-trigger guard evaluation failed",
"trigger", entry.description, "error", err)
@ -150,7 +163,7 @@ func (te *TriggerEngine) makeAfterHook(entry triggerEntry) AfterHook {
childCtx := withTriggerDepth(ctx, depth+1)
if entry.trigger.Run != nil {
if triggerHasRunAction(eventTriggerForExec(entry)) {
return te.execRun(childCtx, entry, tc)
}
return te.execAction(childCtx, entry, tc)
@ -158,7 +171,18 @@ func (te *TriggerEngine) makeAfterHook(entry triggerEntry) AfterHook {
}
func (te *TriggerEngine) execAction(ctx context.Context, entry triggerEntry, tc *ruki.TriggerContext) error {
result, err := te.executor.ExecAction(entry.trigger, tc)
input := ruki.ExecutionInput{}
if triggerRequiresCreateTemplate(eventTriggerForExec(entry)) {
tmpl, err := te.gate.ReadStore().NewTaskTemplate()
if err != nil {
return fmt.Errorf("create template: %w", err)
}
if tmpl == nil {
return fmt.Errorf("create template: store returned nil template")
}
input.CreateTemplate = tmpl
}
result, err := te.executor.ExecAction(eventTriggerForExec(entry), tc, input)
if err != nil {
return fmt.Errorf("trigger %q action execution failed: %w", entry.description, err)
}
@ -176,12 +200,6 @@ func (te *TriggerEngine) persistResult(ctx context.Context, result *ruki.Result)
}
case result.Create != nil:
t := result.Create.Task
tmpl, err := te.gate.ReadStore().NewTaskTemplate()
if err != nil {
return fmt.Errorf("create template: %w", err)
}
t.ID = tmpl.ID
t.CreatedBy = tmpl.CreatedBy
if err := te.gate.CreateTask(ctx, t); err != nil {
return fmt.Errorf("trigger create failed: %w", err)
}
@ -196,7 +214,7 @@ func (te *TriggerEngine) persistResult(ctx context.Context, result *ruki.Result)
}
func (te *TriggerEngine) execRun(ctx context.Context, entry triggerEntry, tc *ruki.TriggerContext) error {
cmdStr, err := te.executor.ExecRun(entry.trigger, tc)
cmdStr, err := te.executor.ExecRun(eventTriggerForExec(entry), tc)
if err != nil {
return fmt.Errorf("trigger %q run evaluation failed: %w", entry.description, err)
}
@ -251,22 +269,28 @@ func LoadAndRegisterTriggers(gate *TaskMutationGate, schema ruki.Schema, userFun
desc = fmt.Sprintf("#%d", i+1)
}
rule, err := parser.ParseRule(def.Ruki)
rule, err := parser.ParseAndValidateRule(def.Ruki)
if err != nil {
return empty(), 0, fmt.Errorf("trigger %q: %w", desc, err)
}
switch {
case rule.TimeTrigger != nil:
switch r := rule.(type) {
case ruki.ValidatedTimeRule:
vtt := r.TimeTrigger()
timeEntries = append(timeEntries, TimeTriggerEntry{
Description: def.Description,
Trigger: rule.TimeTrigger,
Trigger: cloneTimeTriggerForService(vtt.TimeTriggerClone()),
Validated: vtt,
})
case rule.Trigger != nil:
case ruki.ValidatedEventRule:
vt := r.Trigger()
eventEntries = append(eventEntries, triggerEntry{
description: def.Description,
trigger: rule.Trigger,
trigger: cloneTriggerForService(vt.TriggerClone()),
validated: vt,
})
default:
return empty(), 0, fmt.Errorf("trigger %q: unknown validated rule type %T", desc, rule)
}
}
@ -287,7 +311,13 @@ func (te *TriggerEngine) StartScheduler(ctx context.Context) {
return
}
for _, entry := range te.timeTriggers {
d, err := duration.ToDuration(entry.Trigger.Interval.Value, entry.Trigger.Interval.Unit)
interval, ok := timeTriggerInterval(entry)
if !ok {
slog.Warn("skipping time trigger with missing interval metadata",
"trigger", entry.Description)
continue
}
d, err := duration.ToDuration(interval.Value, interval.Unit)
if err != nil {
slog.Error("invalid time trigger interval, skipping",
"trigger", entry.Description, "error", err)
@ -318,7 +348,20 @@ func (te *TriggerEngine) runTimeTrigger(ctx context.Context, entry TimeTriggerEn
// executeTimeTrigger runs a single tick of a time trigger: snapshot tasks, execute, persist.
func (te *TriggerEngine) executeTimeTrigger(ctx context.Context, entry TimeTriggerEntry) {
allTasks := te.gate.ReadStore().GetAllTasks()
result, err := te.executor.ExecTimeTriggerAction(entry.Trigger, allTasks)
input := ruki.ExecutionInput{}
if timeTriggerRequiresCreateTemplate(timeTriggerForExec(entry)) {
tmpl, err := te.gate.ReadStore().NewTaskTemplate()
if err != nil {
slog.Error("create template failed", "trigger", entry.Description, "error", err)
return
}
if tmpl == nil {
slog.Error("create template failed", "trigger", entry.Description, "error", "store returned nil template")
return
}
input.CreateTemplate = tmpl
}
result, err := te.executor.ExecTimeTriggerAction(timeTriggerForExec(entry), allTasks, input)
if err != nil {
slog.Error("time trigger action failed",
"trigger", entry.Description, "error", err)
@ -329,3 +372,124 @@ func (te *TriggerEngine) executeTimeTrigger(ctx context.Context, entry TimeTrigg
"trigger", entry.Description, "error", err)
}
}
func triggerTimingEvent(entry triggerEntry) (string, string, bool) {
switch {
case entry.validated != nil:
timing, event := entry.validated.Timing(), entry.validated.Event()
if timing == "" || event == "" {
return "", "", false
}
return timing, event, true
case entry.trigger != nil:
if entry.trigger.Timing == "" || entry.trigger.Event == "" {
return "", "", false
}
return entry.trigger.Timing, entry.trigger.Event, true
default:
return "", "", false
}
}
func timeTriggerInterval(entry TimeTriggerEntry) (ruki.DurationLiteral, bool) {
switch {
case entry.Validated != nil:
interval := entry.Validated.IntervalLiteral()
if interval.Unit == "" {
return ruki.DurationLiteral{}, false
}
return interval, true
case entry.Trigger != nil:
if entry.Trigger.Interval.Unit == "" {
return ruki.DurationLiteral{}, false
}
return entry.Trigger.Interval, true
default:
return ruki.DurationLiteral{}, false
}
}
func triggerDenyMessage(trig any) (string, bool) {
switch t := trig.(type) {
case *ruki.ValidatedTrigger:
return t.DenyMessage()
case *ruki.Trigger:
if t.Deny == nil {
return "", false
}
return *t.Deny, true
default:
return "", false
}
}
func triggerHasRunAction(trig any) bool {
switch t := trig.(type) {
case *ruki.ValidatedTrigger:
return t.HasRunAction()
case *ruki.Trigger:
return t.Run != nil
default:
return false
}
}
func triggerRequiresCreateTemplate(trig any) bool {
switch t := trig.(type) {
case *ruki.ValidatedTrigger:
return t.RequiresCreateTemplate()
case *ruki.Trigger:
return t != nil && t.Action != nil && t.Action.Create != nil
default:
return false
}
}
func timeTriggerRequiresCreateTemplate(trig any) bool {
switch t := trig.(type) {
case *ruki.ValidatedTimeTrigger:
return t.RequiresCreateTemplate()
case *ruki.TimeTrigger:
return t != nil && t.Action != nil && t.Action.Create != nil
default:
return false
}
}
func eventTriggerForExec(entry triggerEntry) any {
if entry.validated != nil {
return entry.validated
}
return entry.trigger
}
func timeTriggerForExec(entry TimeTriggerEntry) any {
if entry.Validated != nil {
return entry.Validated
}
return entry.Trigger
}
func cloneTriggerForService(trig *ruki.Trigger) *ruki.Trigger {
if trig == nil {
return nil
}
return &ruki.Trigger{
Timing: trig.Timing,
Event: trig.Event,
Where: trig.Where,
Action: trig.Action,
Run: trig.Run,
Deny: trig.Deny,
}
}
func cloneTimeTriggerForService(tt *ruki.TimeTrigger) *ruki.TimeTrigger {
if tt == nil {
return nil
}
return &ruki.TimeTrigger{
Interval: tt.Interval,
Action: tt.Action,
}
}

View file

@ -0,0 +1,101 @@
package service
import (
"context"
"strings"
"testing"
"github.com/boolean-maybe/tiki/ruki"
"github.com/boolean-maybe/tiki/task"
)
func TestTriggerEngine_ValidatedOnlyBeforeEntryDenies(t *testing.T) {
p := ruki.NewParser(testTriggerSchema{})
validated, err := p.ParseAndValidateTrigger(`before create deny "blocked by validated trigger"`, ruki.ExecutorRuntimeEventTrigger)
if err != nil {
t.Fatalf("validate: %v", err)
}
entry := triggerEntry{
description: "validated-only",
validated: validated,
}
gate, _ := newGateWithStoreAndTasks()
engine := NewTriggerEngine([]triggerEntry{entry}, nil, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
engine.RegisterWithGate(gate)
err = gate.CreateTask(context.Background(), &task.Task{
ID: "TIKI-VAL001",
Title: "should be blocked",
Status: "ready",
Type: "story",
Priority: 3,
})
if err == nil {
t.Fatal("expected create denial")
}
if !strings.Contains(err.Error(), "blocked by validated trigger") {
t.Fatalf("expected validated deny message, got: %v", err)
}
}
func TestTriggerEngine_EmptyEventEntryIsSkipped(t *testing.T) {
gate, _ := newGateWithStoreAndTasks()
engine := NewTriggerEngine([]triggerEntry{{description: "empty"}}, nil, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
engine.RegisterWithGate(gate)
if len(engine.beforeCreate)+len(engine.beforeUpdate)+len(engine.beforeDelete)+len(engine.afterCreate)+len(engine.afterUpdate)+len(engine.afterDelete) != 0 {
t.Fatal("expected empty event entry to be skipped")
}
err := gate.CreateTask(context.Background(), &task.Task{
ID: "TIKI-EMP001",
Title: "allowed",
Status: "ready",
Type: "story",
Priority: 3,
})
if err != nil {
t.Fatalf("unexpected create error: %v", err)
}
}
func TestTriggerEngine_ValidatedOnlyTimeEntryExecutes(t *testing.T) {
p := ruki.NewParser(testTriggerSchema{})
validated, err := p.ParseAndValidateTimeTrigger(`every 1day create title="from validated time trigger" priority=3 status="ready" type="story"`, ruki.ExecutorRuntimeTimeTrigger)
if err != nil {
t.Fatalf("validate: %v", err)
}
gate, s := newGateWithStoreAndTasks()
engine := NewTriggerEngine(nil, []TimeTriggerEntry{
{
Description: "validated-time",
Validated: validated,
},
}, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
engine.RegisterWithGate(gate)
engine.executeTimeTrigger(context.Background(), engine.timeTriggers[0])
all := s.GetAllTasks()
if len(all) != 1 {
t.Fatalf("expected one created task, got %d", len(all))
}
if all[0].Title != "from validated time trigger" {
t.Fatalf("expected created title to match, got %q", all[0].Title)
}
}
func TestTriggerEngine_StartSchedulerEmptyTimeEntryNoPanic(t *testing.T) {
gate, _ := newGateWithStoreAndTasks()
engine := NewTriggerEngine(nil, []TimeTriggerEntry{
{Description: "empty-time-entry"},
}, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
engine.RegisterWithGate(gate)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
engine.StartScheduler(ctx)
}