From 51277a1f5925c8c009d62ce989041fef5e365774 Mon Sep 17 00:00:00 2001 From: orestis Date: Thu, 29 Jan 2026 14:25:16 +0000 Subject: [PATCH 1/3] Added builder --- builder.go | 146 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 builder.go diff --git a/builder.go b/builder.go new file mode 100644 index 0000000..d9f23b2 --- /dev/null +++ b/builder.go @@ -0,0 +1,146 @@ +package taskgraph + +import ( + tg "github.com/thought-machine/taskgraph" +) + +// TaskBuilder helps construct taskgraph Tasks with a fluent API. +type TaskBuilder[T any] struct { + name string + resultKey tg.Key[T] + depends []any + fn any + condition tg.Condition + defaultVal T + defaultSet bool + defaultBindings []tg.Binding +} + +// NewTaskBuilder creates a new builder for a task that produces a result of type T. +func NewTaskBuilder[T any](name string, key tg.Key[T]) *TaskBuilder[T] { + return &TaskBuilder[T]{ + name: name, + resultKey: key, + } +} + +// DependsOn adds dependencies to the task. +func (b *TaskBuilder[T]) DependsOn(deps ...any) *TaskBuilder[T] { + b.depends = append(b.depends, deps...) + return b +} + +// Run sets the function to execute. The function signature must match the dependencies. +func (b *TaskBuilder[T]) Run(fn any) *TaskBuilder[T] { + b.fn = fn + return b +} + +// RunIf sets a condition for the task execution. +func (b *TaskBuilder[T]) RunIf(cond tg.Condition) *TaskBuilder[T] { + b.condition = cond + return b +} + +// Default sets the default value for the result key if the condition is false. +func (b *TaskBuilder[T]) Default(val T) *TaskBuilder[T] { + b.defaultVal = val + b.defaultSet = true + return b +} + +// WithDefaultBindings adds arbitrary default bindings if the condition is false. +func (b *TaskBuilder[T]) WithDefaultBindings(bindings ...tg.Binding) *TaskBuilder[T] { + b.defaultBindings = append(b.defaultBindings, bindings...) + return b +} + +// Build constructs and returns the Task. +func (b *TaskBuilder[T]) Build() tg.TaskSet { + reflect := tg.Reflect[T]{ + Name: b.name, + ResultKey: b.resultKey, + Depends: b.depends, + Fn: b.fn, + } + var task tg.TaskSet = reflect.Locate() + + if b.condition != nil { + conditional := tg.Conditional{ + Wrapped: task, + Condition: b.condition, + } + + if b.defaultSet { + conditional.DefaultBindings = append(conditional.DefaultBindings, b.resultKey.Bind(b.defaultVal)) + } + conditional.DefaultBindings = append(conditional.DefaultBindings, b.defaultBindings...) + + task = conditional.Locate() + } + + return task +} + +// EffectTaskBuilder helps construct taskgraph Tasks that perform side effects (no result key). +type EffectTaskBuilder struct { + name string + depends []any + fn any + condition tg.Condition + defaultBindings []tg.Binding +} + +// NewEffectTaskBuilder creates a new builder for a side-effect task. +func NewEffectTaskBuilder(name string) *EffectTaskBuilder { + return &EffectTaskBuilder{ + name: name, + } +} + +// DependsOn adds dependencies to the task. +func (b *EffectTaskBuilder) DependsOn(deps ...any) *EffectTaskBuilder { + b.depends = append(b.depends, deps...) + return b +} + +// Run sets the function to execute. The function signature must match the dependencies. +// Fn must return []tg.Binding or ([]tg.Binding, error). +func (b *EffectTaskBuilder) Run(fn any) *EffectTaskBuilder { + b.fn = fn + return b +} + +// RunIf sets a condition for the task execution. +func (b *EffectTaskBuilder) RunIf(cond tg.Condition) *EffectTaskBuilder { + b.condition = cond + return b +} + +// WithDefaultBindings adds arbitrary default bindings if the condition is false. +func (b *EffectTaskBuilder) WithDefaultBindings(bindings ...tg.Binding) *EffectTaskBuilder { + b.defaultBindings = append(b.defaultBindings, bindings...) + return b +} + +// Build constructs and returns the Task. +func (b *EffectTaskBuilder) Build() tg.TaskSet { + reflect := tg.ReflectMulti{ + Name: b.name, + Depends: b.depends, + Fn: b.fn, + Provides: nil, + } + var task tg.TaskSet = reflect.Locate() + + if b.condition != nil { + conditional := tg.Conditional{ + Wrapped: task, + Condition: b.condition, + DefaultBindings: b.defaultBindings, + } + task = conditional.Locate() + } + + return task +} From 036cc99161e0215204d09f07feb9fc30d30a0bd9 Mon Sep 17 00:00:00 2001 From: orestis Date: Thu, 29 Jan 2026 14:31:35 +0000 Subject: [PATCH 2/3] Fix source location reporting in builder and refactor getLocation --- builder.go | 50 +++++++++++++++++++++++++------------------------- key.go | 10 +++++----- reflect.go | 4 ++-- task.go | 14 +++++++------- util.go | 6 +++--- 5 files changed, 42 insertions(+), 42 deletions(-) diff --git a/builder.go b/builder.go index d9f23b2..ef82510 100644 --- a/builder.go +++ b/builder.go @@ -1,23 +1,19 @@ package taskgraph -import ( - tg "github.com/thought-machine/taskgraph" -) - // TaskBuilder helps construct taskgraph Tasks with a fluent API. type TaskBuilder[T any] struct { name string - resultKey tg.Key[T] + resultKey Key[T] depends []any fn any - condition tg.Condition + condition Condition defaultVal T defaultSet bool - defaultBindings []tg.Binding + defaultBindings []Binding } // NewTaskBuilder creates a new builder for a task that produces a result of type T. -func NewTaskBuilder[T any](name string, key tg.Key[T]) *TaskBuilder[T] { +func NewTaskBuilder[T any](name string, key Key[T]) *TaskBuilder[T] { return &TaskBuilder[T]{ name: name, resultKey: key, @@ -37,7 +33,7 @@ func (b *TaskBuilder[T]) Run(fn any) *TaskBuilder[T] { } // RunIf sets a condition for the task execution. -func (b *TaskBuilder[T]) RunIf(cond tg.Condition) *TaskBuilder[T] { +func (b *TaskBuilder[T]) RunIf(cond Condition) *TaskBuilder[T] { b.condition = cond return b } @@ -50,23 +46,24 @@ func (b *TaskBuilder[T]) Default(val T) *TaskBuilder[T] { } // WithDefaultBindings adds arbitrary default bindings if the condition is false. -func (b *TaskBuilder[T]) WithDefaultBindings(bindings ...tg.Binding) *TaskBuilder[T] { +func (b *TaskBuilder[T]) WithDefaultBindings(bindings ...Binding) *TaskBuilder[T] { b.defaultBindings = append(b.defaultBindings, bindings...) return b } // Build constructs and returns the Task. -func (b *TaskBuilder[T]) Build() tg.TaskSet { - reflect := tg.Reflect[T]{ +func (b *TaskBuilder[T]) Build() TaskSet { + reflect := Reflect[T]{ Name: b.name, ResultKey: b.resultKey, Depends: b.depends, Fn: b.fn, } - var task tg.TaskSet = reflect.Locate() + reflect.location = getLocation(2) + var task TaskSet = reflect if b.condition != nil { - conditional := tg.Conditional{ + conditional := Conditional{ Wrapped: task, Condition: b.condition, } @@ -76,7 +73,8 @@ func (b *TaskBuilder[T]) Build() tg.TaskSet { } conditional.DefaultBindings = append(conditional.DefaultBindings, b.defaultBindings...) - task = conditional.Locate() + conditional.location = getLocation(2) + task = conditional } return task @@ -87,8 +85,8 @@ type EffectTaskBuilder struct { name string depends []any fn any - condition tg.Condition - defaultBindings []tg.Binding + condition Condition + defaultBindings []Binding } // NewEffectTaskBuilder creates a new builder for a side-effect task. @@ -105,41 +103,43 @@ func (b *EffectTaskBuilder) DependsOn(deps ...any) *EffectTaskBuilder { } // Run sets the function to execute. The function signature must match the dependencies. -// Fn must return []tg.Binding or ([]tg.Binding, error). +// Fn must return []Binding or ([]Binding, error). func (b *EffectTaskBuilder) Run(fn any) *EffectTaskBuilder { b.fn = fn return b } // RunIf sets a condition for the task execution. -func (b *EffectTaskBuilder) RunIf(cond tg.Condition) *EffectTaskBuilder { +func (b *EffectTaskBuilder) RunIf(cond Condition) *EffectTaskBuilder { b.condition = cond return b } // WithDefaultBindings adds arbitrary default bindings if the condition is false. -func (b *EffectTaskBuilder) WithDefaultBindings(bindings ...tg.Binding) *EffectTaskBuilder { +func (b *EffectTaskBuilder) WithDefaultBindings(bindings ...Binding) *EffectTaskBuilder { b.defaultBindings = append(b.defaultBindings, bindings...) return b } // Build constructs and returns the Task. -func (b *EffectTaskBuilder) Build() tg.TaskSet { - reflect := tg.ReflectMulti{ +func (b *EffectTaskBuilder) Build() TaskSet { + reflect := ReflectMulti{ Name: b.name, Depends: b.depends, Fn: b.fn, Provides: nil, } - var task tg.TaskSet = reflect.Locate() + reflect.location = getLocation(2) + var task TaskSet = reflect if b.condition != nil { - conditional := tg.Conditional{ + conditional := Conditional{ Wrapped: task, Condition: b.condition, DefaultBindings: b.defaultBindings, } - task = conditional.Locate() + conditional.location = getLocation(2) + task = conditional } return task diff --git a/key.go b/key.go index 9a8146e..5e9e7d4 100644 --- a/key.go +++ b/key.go @@ -100,7 +100,7 @@ func (k *key[T]) Get(b Binder) (T, error) { func NewKey[T any](id string) Key[T] { return &key[T]{ id: newID("", id), - location: getLocation(), + location: getLocation(2), } } @@ -109,7 +109,7 @@ func NewKey[T any](id string) Key[T] { func NewNamespacedKey[T any](namespace, id string) Key[T] { return &key[T]{ id: newID(namespace, id), - location: getLocation(), + location: getLocation(2), } } @@ -130,7 +130,7 @@ func (k *presenceKey[T]) Get(b Binder) (bool, error) { func Presence[T any](key ReadOnlyKey[T]) ReadOnlyKey[bool] { return &presenceKey[T]{ ReadOnlyKey: key, - location: getLocation(), + location: getLocation(2), } } @@ -159,7 +159,7 @@ func Mapped[In, Out any](key ReadOnlyKey[In], fn func(In) Out) ReadOnlyKey[Out] return &mappedKey[In, Out]{ ReadOnlyKey: key, fn: fn, - location: getLocation(), + location: getLocation(2), } } @@ -188,6 +188,6 @@ func (k *optionalKey[T]) Get(b Binder) (Maybe[T], error) { func Optional[T any](base ReadOnlyKey[T]) ReadOnlyKey[Maybe[T]] { return &optionalKey[T]{ ReadOnlyKey: base, - location: getLocation(), + location: getLocation(2), } } diff --git a/reflect.go b/reflect.go index f565064..84ee36e 100644 --- a/reflect.go +++ b/reflect.go @@ -258,7 +258,7 @@ type Reflect[T any] struct { // Locate annotates the Reflect with its location in the source code, to make error messages // easier to understand. Calling it is recommended but not required if wrapped in a Conditional func (r Reflect[T]) Locate() Reflect[T] { - r.location = getLocation() + r.location = getLocation(2) return r } @@ -337,7 +337,7 @@ type ReflectMulti struct { // Locate annotates the ReflectMulti with its location in the source code, to make error messages // easier to understand. Calling it is recommended but not required if wrapped in a Conditional func (r ReflectMulti) Locate() ReflectMulti { - r.location = getLocation() + r.location = getLocation(2) return r } diff --git a/task.go b/task.go index 7ef3502..9d5b2ec 100644 --- a/task.go +++ b/task.go @@ -91,7 +91,7 @@ func NewTask( depends: depends, provides: provides, fn: fn, - location: getLocation(), + location: getLocation(2), } } @@ -103,7 +103,7 @@ func NoOutputTask(name string, fn func(ctx context.Context, b Binder) error, dep fn: func(ctx context.Context, b Binder) ([]Binding, error) { return nil, fn(ctx, b) }, - location: getLocation(), + location: getLocation(2), } } @@ -125,7 +125,7 @@ func SimpleTask[T any]( } return []Binding{key.Bind(val)}, nil }, - location: getLocation(), + location: getLocation(2), } } @@ -151,7 +151,7 @@ func SimpleTask1[A1, Res any]( } return []Binding{resKey.Bind(res)}, nil }, - location: getLocation(), + location: getLocation(2), } } @@ -182,7 +182,7 @@ func SimpleTask2[A1, A2, Res any]( } return []Binding{resKey.Bind(res)}, nil }, - location: getLocation(), + location: getLocation(2), } } @@ -266,7 +266,7 @@ type Conditional struct { // Locate annotates the Conditional with its location in the source code, to make error messages // easier to understand. Calling it is required. func (c Conditional) Locate() Conditional { - c.location = getLocation() + c.location = getLocation(2) return c } @@ -321,6 +321,6 @@ func AllBound(name string, result Key[bool], deps ...ID) Task { fn: func(_ context.Context, _ Binder) ([]Binding, error) { return []Binding{result.Bind(true)}, nil }, - location: getLocation(), + location: getLocation(2), } } diff --git a/util.go b/util.go index 8592a6b..60face8 100644 --- a/util.go +++ b/util.go @@ -135,9 +135,9 @@ func MissingMaybe(maybes map[string]MaybeStatus) []string { return out } -func getLocation() string { - // Skip 1 for this function, and 1 for the constructor calling this. - if _, file, line, ok := runtime.Caller(2); ok { +func getLocation(skip int) string { + // Skip the requested number of stack frames. + if _, file, line, ok := runtime.Caller(skip); ok { return fmt.Sprintf("%s:%d", file, line) } return "" From 4ded8ffb5d2bc8a99ee135e6dcaed2b760373582 Mon Sep 17 00:00:00 2001 From: orestis Date: Thu, 29 Jan 2026 14:47:11 +0000 Subject: [PATCH 3/3] Refactor TaskBuilder: rename EffectTaskBuilder to MultiTaskBuilder, add Provides(), RunIfAll/Any --- builder.go | 67 +++++++++++++++++++++++++++++++++++++++--------- builder_test.go | 68 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 12 deletions(-) create mode 100644 builder_test.go diff --git a/builder.go b/builder.go index ef82510..7b86bda 100644 --- a/builder.go +++ b/builder.go @@ -1,5 +1,7 @@ package taskgraph +import "fmt" + // TaskBuilder helps construct taskgraph Tasks with a fluent API. type TaskBuilder[T any] struct { name string @@ -38,6 +40,18 @@ func (b *TaskBuilder[T]) RunIf(cond Condition) *TaskBuilder[T] { return b } +// RunIfAll sets a ConditionAnd (logical AND) for the task execution using the provided keys. +func (b *TaskBuilder[T]) RunIfAll(keys ...ReadOnlyKey[bool]) *TaskBuilder[T] { + b.condition = ConditionAnd(keys) + return b +} + +// RunIfAny sets a ConditionOr (logical OR) for the task execution using the provided keys. +func (b *TaskBuilder[T]) RunIfAny(keys ...ReadOnlyKey[bool]) *TaskBuilder[T] { + b.condition = ConditionOr(keys) + return b +} + // Default sets the default value for the result key if the condition is false. func (b *TaskBuilder[T]) Default(val T) *TaskBuilder[T] { b.defaultVal = val @@ -80,54 +94,83 @@ func (b *TaskBuilder[T]) Build() TaskSet { return task } -// EffectTaskBuilder helps construct taskgraph Tasks that perform side effects (no result key). -type EffectTaskBuilder struct { +// MultiTaskBuilder helps construct taskgraph Tasks that provide multiple outputs or perform side effects. +type MultiTaskBuilder struct { name string depends []any fn any + provides []ID condition Condition defaultBindings []Binding } -// NewEffectTaskBuilder creates a new builder for a side-effect task. -func NewEffectTaskBuilder(name string) *EffectTaskBuilder { - return &EffectTaskBuilder{ +// NewMultiTaskBuilder creates a new builder for a multi-output or side-effect task. +func NewMultiTaskBuilder(name string) *MultiTaskBuilder { + return &MultiTaskBuilder{ name: name, } } // DependsOn adds dependencies to the task. -func (b *EffectTaskBuilder) DependsOn(deps ...any) *EffectTaskBuilder { +func (b *MultiTaskBuilder) DependsOn(deps ...any) *MultiTaskBuilder { b.depends = append(b.depends, deps...) return b } +// Provides declares the keys that this task provides. +func (b *MultiTaskBuilder) Provides(keys ...any) *MultiTaskBuilder { + for _, k := range keys { + rk, err := newReflectKey(k) + if err != nil { + panic(fmt.Errorf("invalid key passed to Provides: %w", err)) + } + id, err := rk.ID() + if err != nil { + panic(fmt.Errorf("invalid key ID in Provides: %w", err)) + } + b.provides = append(b.provides, id) + } + return b +} + // Run sets the function to execute. The function signature must match the dependencies. // Fn must return []Binding or ([]Binding, error). -func (b *EffectTaskBuilder) Run(fn any) *EffectTaskBuilder { +func (b *MultiTaskBuilder) Run(fn any) *MultiTaskBuilder { b.fn = fn return b } // RunIf sets a condition for the task execution. -func (b *EffectTaskBuilder) RunIf(cond Condition) *EffectTaskBuilder { +func (b *MultiTaskBuilder) RunIf(cond Condition) *MultiTaskBuilder { b.condition = cond return b } +// RunIfAll sets a ConditionAnd (logical AND) for the task execution using the provided keys. +func (b *MultiTaskBuilder) RunIfAll(keys ...ReadOnlyKey[bool]) *MultiTaskBuilder { + b.condition = ConditionAnd(keys) + return b +} + +// RunIfAny sets a ConditionOr (logical OR) for the task execution using the provided keys. +func (b *MultiTaskBuilder) RunIfAny(keys ...ReadOnlyKey[bool]) *MultiTaskBuilder { + b.condition = ConditionOr(keys) + return b +} + // WithDefaultBindings adds arbitrary default bindings if the condition is false. -func (b *EffectTaskBuilder) WithDefaultBindings(bindings ...Binding) *EffectTaskBuilder { +func (b *MultiTaskBuilder) WithDefaultBindings(bindings ...Binding) *MultiTaskBuilder { b.defaultBindings = append(b.defaultBindings, bindings...) return b } // Build constructs and returns the Task. -func (b *EffectTaskBuilder) Build() TaskSet { +func (b *MultiTaskBuilder) Build() TaskSet { reflect := ReflectMulti{ Name: b.name, Depends: b.depends, Fn: b.fn, - Provides: nil, + Provides: b.provides, } reflect.location = getLocation(2) var task TaskSet = reflect @@ -143,4 +186,4 @@ func (b *EffectTaskBuilder) Build() TaskSet { } return task -} +} \ No newline at end of file diff --git a/builder_test.go b/builder_test.go new file mode 100644 index 0000000..a88f9bb --- /dev/null +++ b/builder_test.go @@ -0,0 +1,68 @@ +package taskgraph + +import ( + "testing" +) + +func TestTaskBuilder_RunIfAll(t *testing.T) { + k1 := NewKey[bool]("k1") + k2 := NewKey[bool]("k2") + res := NewKey[string]("res") + + task := NewTaskBuilder[string]("test", res). + Run(func() string { return "ok" }). + RunIfAll(k1, k2). + Default("default"). + Build() + + // Simulate execution (simplified verification) + tasks := task.Tasks() + if len(tasks) != 1 { + t.Fatalf("expected 1 task, got %d", len(tasks)) + } + // We can't easily execute it without a full graph, but we can check if it didn't panic and produced a task. +} + +func TestMultiTaskBuilder_Provides(t *testing.T) { + k1 := NewKey[string]("k1") + k2 := NewKey[int]("k2") + + task := NewMultiTaskBuilder("multi"). + Provides(k1, k2). + Run(func() ([]Binding, error) { + return []Binding{k1.Bind("s"), k2.Bind(1)}, nil + }). + Build() + + tasks := task.Tasks() + if len(tasks) != 1 { + t.Fatalf("expected 1 task, got %d", len(tasks)) + } + provided := tasks[0].Provides() + if len(provided) != 2 { + t.Fatalf("expected 2 provided keys, got %d", len(provided)) + } +} + +func TestMultiTaskBuilder_Provides_InvalidKey(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Errorf("expected panic on invalid key") + } + }() + NewMultiTaskBuilder("fail").Provides("not a key") +} + +func TestMultiTaskBuilder_RunIfAny(t *testing.T) { + k1 := NewKey[bool]("k1") + k2 := NewKey[bool]("k2") + + task := NewMultiTaskBuilder("multi_cond"). + RunIfAny(k1, k2). + Run(func() []Binding { return nil }). + Build() + + if task == nil { + t.Fatal("expected task to be built") + } +}