relay-flow 0.3.7-alpha → 0.3.9-alpha
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -5
- package/cmd/relay-flow/observability_test.go +28 -0
- package/cmd/relay-flow/render.go +20 -4
- package/cmd/relay-flow/scenario_test.go +15 -13
- package/cmd/relay-flow/serve.go +116 -103
- package/internal/execution/goworkflows/activities.go +17 -0
- package/internal/execution/goworkflows/cancellation_test.go +50 -0
- package/internal/execution/goworkflows/engine.go +278 -9
- package/internal/execution/goworkflows/fakes_test.go +10 -1
- package/internal/execution/goworkflows/interpreter.go +17 -1
- package/internal/execution/goworkflows/projection.go +8 -0
- package/internal/execution/goworkflows/recovery_test.go +482 -0
- package/internal/execution/projection/detail_test.go +38 -0
- package/internal/execution/projection/projection.go +80 -15
- package/internal/execution/temporal/activities.go +6 -0
- package/internal/execution/temporal/recovery.go +4 -0
- package/internal/harness/opencode/opencode_test.go +1 -1
- package/internal/harness/opencode/repo_setup.go +1 -1
- package/internal/recover/recover.go +4 -0
- package/internal/repo/binding_test.go +92 -0
- package/internal/repo/poller.go +3 -0
- package/internal/repo/repo.go +82 -6
- package/internal/run/manager.go +41 -0
- package/internal/run/run_manager_test.go +56 -0
- package/internal/server/observability.go +1 -1
- package/internal/task/beads/beads.go +42 -4
- package/internal/task/beads/beads_test.go +15 -0
- package/internal/task/factory.go +41 -2
- package/internal/task/jira/jira.go +52 -28
- package/internal/workflow/service.go +53 -0
- package/internal/workflow/store.go +413 -19
- package/internal/workflow/store_test.go +235 -0
- package/internal/workflow/workflow.go +71 -0
- package/package.json +1 -1
|
@@ -257,24 +257,69 @@ func (p *RunProjection) insertStart(ctx context.Context, s run.Start, now time.T
|
|
|
257
257
|
}
|
|
258
258
|
|
|
259
259
|
func (p *RunProjection) updateState(ctx context.Context, id run.ID, state run.State, lastErr string, finished *time.Time) error {
|
|
260
|
+
_, err := p.updateStateCAS(ctx, id, nil, state, lastErr, finished)
|
|
261
|
+
return err
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// beginCancellation atomically claims cancellation of a nonterminal run. A
|
|
265
|
+
// run already in canceling keeps its original reason; completed/canceled runs
|
|
266
|
+
// are never moved backwards. The returned row is read after the conditional
|
|
267
|
+
// update so concurrent cancel/complete callers observe the winner.
|
|
268
|
+
func (p *RunProjection) beginCancellation(ctx context.Context, id run.ID, reason string) (run.Run, error) {
|
|
269
|
+
_, err := p.DB.ExecContext(ctx, `
|
|
270
|
+
UPDATE relay_runs SET state = ?, last_error = ?, updated_at = ?
|
|
271
|
+
WHERE id = ? AND state NOT IN ('completed', 'canceled', 'canceling')`,
|
|
272
|
+
string(run.StateCanceling), reason, time.Now().UTC(), string(id))
|
|
273
|
+
if err != nil {
|
|
274
|
+
return run.Run{}, err
|
|
275
|
+
}
|
|
276
|
+
return p.get(ctx, id)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
func (p *RunProjection) updateStateCAS(ctx context.Context, id run.ID, expected *run.State, state run.State, lastErr string, finished *time.Time) (bool, error) {
|
|
260
280
|
terminal := state == run.StateCompleted || state == run.StateCanceled
|
|
261
281
|
now := time.Now().UTC()
|
|
262
|
-
|
|
282
|
+
query := `
|
|
263
283
|
UPDATE relay_runs SET state = ?, last_error = ?, updated_at = ?, finished_at = COALESCE(?, finished_at),
|
|
264
284
|
retry_error = CASE WHEN ? THEN NULL ELSE retry_error END,
|
|
265
285
|
retry_attempt = CASE WHEN ? THEN NULL ELSE retry_attempt END,
|
|
266
286
|
next_retry_at = CASE WHEN ? THEN NULL ELSE next_retry_at END
|
|
267
|
-
WHERE id =
|
|
268
|
-
|
|
287
|
+
WHERE id = ?`
|
|
288
|
+
args := []any{
|
|
289
|
+
string(state), lastErr, now, finished, terminal, terminal, terminal,
|
|
290
|
+
string(id),
|
|
291
|
+
}
|
|
292
|
+
if expected == nil {
|
|
293
|
+
// Ordinary workflow projection updates are fenced after cancellation or
|
|
294
|
+
// any terminal state has won. Explicit compare-and-set callers below
|
|
295
|
+
// are allowed to reconcile a known canceling row to its inspected
|
|
296
|
+
// terminal engine result.
|
|
297
|
+
query += ` AND NOT (
|
|
298
|
+
(state = 'canceling' AND ? NOT IN ('canceling', 'canceled'))
|
|
299
|
+
OR (state IN ('completed', 'canceled') AND state <> ?)
|
|
300
|
+
)`
|
|
301
|
+
args = append(args, string(state), string(state))
|
|
302
|
+
} else {
|
|
303
|
+
query += ` AND state = ? AND NOT (state IN ('completed', 'canceled') AND state <> ?)`
|
|
304
|
+
args = append(args, string(*expected), string(state))
|
|
305
|
+
}
|
|
306
|
+
result, err := p.DB.ExecContext(ctx, query, args...)
|
|
269
307
|
if err != nil {
|
|
270
|
-
return err
|
|
308
|
+
return false, err
|
|
309
|
+
}
|
|
310
|
+
updated, err := result.RowsAffected()
|
|
311
|
+
if err != nil {
|
|
312
|
+
return false, err
|
|
313
|
+
}
|
|
314
|
+
if updated != 1 {
|
|
315
|
+
return false, nil
|
|
271
316
|
}
|
|
272
317
|
// Keep the derived timeline useful during retries and cancellation without
|
|
273
318
|
// making it an execution authority. Identify one active row first; a
|
|
274
319
|
// repeated terminal update with no active row is a no-op for the timeline.
|
|
275
320
|
stepStatus := stepStatusForRunState(state)
|
|
276
321
|
if stepStatus == "" {
|
|
277
|
-
return nil
|
|
322
|
+
return true, nil
|
|
278
323
|
}
|
|
279
324
|
var sequence int64
|
|
280
325
|
var startedAt sql.NullTime
|
|
@@ -283,14 +328,14 @@ func (p *RunProjection) updateState(ctx context.Context, id run.ID, state run.St
|
|
|
283
328
|
WHERE run_id = ? AND status IN ('running', 'waiting', 'blocked')
|
|
284
329
|
ORDER BY sequence DESC LIMIT 1`, string(id)).Scan(&sequence, &startedAt)
|
|
285
330
|
if errors.Is(lookupErr, sql.ErrNoRows) {
|
|
286
|
-
return nil
|
|
331
|
+
return true, nil
|
|
287
332
|
}
|
|
288
333
|
if lookupErr != nil {
|
|
289
334
|
// relay_run_steps is display/cache data. The authoritative relay_runs
|
|
290
335
|
// state update above has already succeeded and must not be retried just
|
|
291
336
|
// because this optional projection is unavailable.
|
|
292
337
|
slog.Warn("step projection state lookup unavailable", "runID", string(id), "state", state, "error", lookupErr)
|
|
293
|
-
return nil
|
|
338
|
+
return true, nil
|
|
294
339
|
}
|
|
295
340
|
var stepFinished any
|
|
296
341
|
if terminal {
|
|
@@ -300,7 +345,7 @@ func (p *RunProjection) updateState(ctx context.Context, id run.ID, state run.St
|
|
|
300
345
|
stepFinished = now
|
|
301
346
|
}
|
|
302
347
|
}
|
|
303
|
-
result, err
|
|
348
|
+
result, err = p.DB.ExecContext(ctx, `
|
|
304
349
|
UPDATE relay_run_steps SET status = ?, message = CASE WHEN ? <> '' THEN ? ELSE message END,
|
|
305
350
|
finished_at = CASE WHEN ? THEN COALESCE(finished_at, ?) ELSE finished_at END
|
|
306
351
|
WHERE run_id = ? AND sequence = ? AND status IN ('running', 'waiting', 'blocked')`,
|
|
@@ -308,11 +353,11 @@ func (p *RunProjection) updateState(ctx context.Context, id run.ID, state run.St
|
|
|
308
353
|
string(id), sequence)
|
|
309
354
|
if err != nil {
|
|
310
355
|
slog.Warn("step projection state update unavailable", "runID", string(id), "state", state, "error", err)
|
|
311
|
-
return nil
|
|
356
|
+
return true, nil
|
|
312
357
|
}
|
|
313
|
-
updated, err
|
|
358
|
+
updated, err = result.RowsAffected()
|
|
314
359
|
if err != nil || updated != 1 {
|
|
315
|
-
return nil
|
|
360
|
+
return true, nil
|
|
316
361
|
}
|
|
317
362
|
if terminal && startedAt.Valid {
|
|
318
363
|
endAt := now
|
|
@@ -327,7 +372,7 @@ func (p *RunProjection) updateState(ctx context.Context, id run.ID, state run.St
|
|
|
327
372
|
slog.Warn("step projection duration update unavailable", "runID", string(id), "state", state, "error", durationErr)
|
|
328
373
|
}
|
|
329
374
|
}
|
|
330
|
-
return nil
|
|
375
|
+
return true, nil
|
|
331
376
|
}
|
|
332
377
|
|
|
333
378
|
func (p *RunProjection) updateRetry(ctx context.Context, id run.ID, status *run.RetryStatus) error {
|
|
@@ -352,12 +397,20 @@ func (p *RunProjection) updateNode(ctx context.Context, id run.ID, state run.Sta
|
|
|
352
397
|
}
|
|
353
398
|
defer tx.Rollback()
|
|
354
399
|
now := time.Now().UTC()
|
|
355
|
-
|
|
400
|
+
result, err := tx.ExecContext(ctx, `
|
|
356
401
|
UPDATE relay_runs SET state = ?, current_node = ?, current_node_visit_id = ?, updated_at = ?
|
|
357
|
-
WHERE id =
|
|
358
|
-
string(state), node, string(visit), now, string(id))
|
|
402
|
+
WHERE id = ? AND state NOT IN ('canceling', 'completed', 'canceled')`,
|
|
403
|
+
string(state), node, string(visit), now, string(id))
|
|
404
|
+
if err != nil {
|
|
359
405
|
return err
|
|
360
406
|
}
|
|
407
|
+
updated, err := result.RowsAffected()
|
|
408
|
+
if err != nil {
|
|
409
|
+
return err
|
|
410
|
+
}
|
|
411
|
+
if updated != 1 {
|
|
412
|
+
return tx.Commit()
|
|
413
|
+
}
|
|
361
414
|
// A revisit changes only the latest visit ID. Reusable terminal/session
|
|
362
415
|
// identities remain attached to this run/node row.
|
|
363
416
|
if _, err := tx.ExecContext(ctx, `
|
|
@@ -907,6 +960,18 @@ func (p *RunProjection) UpdateState(ctx context.Context, id run.ID, state run.St
|
|
|
907
960
|
return p.updateState(ctx, id, state, lastErr, finished)
|
|
908
961
|
}
|
|
909
962
|
|
|
963
|
+
// BeginCancellation atomically moves a nonterminal run to canceling and
|
|
964
|
+
// returns the persisted row. Existing canceling rows retain their reason.
|
|
965
|
+
func (p *RunProjection) BeginCancellation(ctx context.Context, id run.ID, reason string) (run.Run, error) {
|
|
966
|
+
return p.beginCancellation(ctx, id, reason)
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// UpdateStateIf applies a lifecycle transition only while the row has the
|
|
970
|
+
// expected state. It returns false when another transition won the race.
|
|
971
|
+
func (p *RunProjection) UpdateStateIf(ctx context.Context, id run.ID, expected, state run.State, lastErr string, finished *time.Time) (bool, error) {
|
|
972
|
+
return p.updateStateCAS(ctx, id, &expected, state, lastErr, finished)
|
|
973
|
+
}
|
|
974
|
+
|
|
910
975
|
// UpdateRetry updates or clears active retry metadata.
|
|
911
976
|
func (p *RunProjection) UpdateRetry(ctx context.Context, id run.ID, status *run.RetryStatus) error {
|
|
912
977
|
return p.updateRetry(ctx, id, status)
|
|
@@ -37,6 +37,12 @@ func (a *Activities) taskSystem(repoName string) (task.System, error) {
|
|
|
37
37
|
if !ok {
|
|
38
38
|
return nil, fmt.Errorf("repo %q is not registered", repoName)
|
|
39
39
|
}
|
|
40
|
+
if rp.TaskSystem == nil {
|
|
41
|
+
if rp.TaskSystemError != nil {
|
|
42
|
+
return nil, fmt.Errorf("repo %q task system unavailable: %w", repoName, rp.TaskSystemError)
|
|
43
|
+
}
|
|
44
|
+
return nil, fmt.Errorf("repo %q task system unavailable", repoName)
|
|
45
|
+
}
|
|
40
46
|
return rp.TaskSystem, nil
|
|
41
47
|
}
|
|
42
48
|
|
|
@@ -141,6 +141,10 @@ func listWorkflowExecutions(ctx context.Context, list func(context.Context, *wor
|
|
|
141
141
|
// ID is described and restored only when Temporal already owns that execution.
|
|
142
142
|
func (e *Engine) reconcileClaimedParents(ctx context.Context, visible map[string]bool) error {
|
|
143
143
|
for _, rp := range e.deps.Repos.List() {
|
|
144
|
+
if rp.TaskSystem == nil {
|
|
145
|
+
slog.Warn("Temporal recovery skipping repository with unavailable task system", "repo", rp.Name, "error", rp.TaskSystemError)
|
|
146
|
+
continue
|
|
147
|
+
}
|
|
144
148
|
tickets, err := rp.TaskSystem.Poll(ctx)
|
|
145
149
|
if err != nil {
|
|
146
150
|
return fmt.Errorf("poll repo %q during Temporal recovery: %w", rp.Name, err)
|
|
@@ -14,7 +14,7 @@ import (
|
|
|
14
14
|
"github.com/rajpopat27/relay-flow/internal/workflow"
|
|
15
15
|
)
|
|
16
16
|
|
|
17
|
-
const configuredPlugin = "relay-flow-plugin@0.3.
|
|
17
|
+
const configuredPlugin = "relay-flow-plugin@0.3.9-alpha"
|
|
18
18
|
|
|
19
19
|
func TestBuildCommandArgv(t *testing.T) {
|
|
20
20
|
t.Setenv("RELAY_FLOW_HOME", "/var/lib/relay-flow-test")
|
|
@@ -46,6 +46,10 @@ type MailboxSpecFor func(task.System, run.Work, *workflow.Workflow) ([]task.Mail
|
|
|
46
46
|
// never automatic; database loss is never inferred.
|
|
47
47
|
func FromTaskSystem(ctx context.Context, repoReg *repo.Registry, rnr runner.Runner, runManager *run.RunManager, specsFor MailboxSpecFor) error {
|
|
48
48
|
for _, rp := range repoReg.List() {
|
|
49
|
+
if rp.TaskSystem == nil {
|
|
50
|
+
slog.Warn("recover: skip repository with unavailable task system", "repo", rp.Name, "error", rp.TaskSystemError)
|
|
51
|
+
continue
|
|
52
|
+
}
|
|
49
53
|
tickets, err := rp.TaskSystem.Poll(ctx)
|
|
50
54
|
if err != nil {
|
|
51
55
|
return fmt.Errorf("repo %q poll: %w", rp.Name, err)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
package repo
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"errors"
|
|
5
|
+
"strings"
|
|
6
|
+
"testing"
|
|
7
|
+
|
|
8
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
9
|
+
"github.com/rajpopat27/relay-flow/internal/task"
|
|
10
|
+
"github.com/rajpopat27/relay-flow/internal/workflow"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
type bindingTaskSystem struct {
|
|
14
|
+
task.System
|
|
15
|
+
compileErr map[string]error
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
func (s bindingTaskSystem) CompileFilter(values config.RawValues) (func(task.Ticket) bool, error) {
|
|
19
|
+
if name, ok := values["name"].(string); ok {
|
|
20
|
+
if err := s.compileErr[name]; err != nil {
|
|
21
|
+
return nil, err
|
|
22
|
+
}
|
|
23
|
+
return func(ticket task.Ticket) bool { return ticket.Key == name }, nil
|
|
24
|
+
}
|
|
25
|
+
return func(task.Ticket) bool { return true }, nil
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
func TestBindWorkflowsIsolatedPublishesHealthyWorkflows(t *testing.T) {
|
|
29
|
+
system := bindingTaskSystem{compileErr: map[string]error{"bad": errors.New("invalid filter")}}
|
|
30
|
+
registered := &Repo{Name: "payments", TaskSystem: system}
|
|
31
|
+
registry := NewRegistry()
|
|
32
|
+
registry.Replace(registered)
|
|
33
|
+
|
|
34
|
+
bad := &workflow.Workflow{Name: "badFlow", Repos: []string{"payments"}, Status: workflow.HealthHealthy, TaskConfig: config.RawValues{"name": "bad"}}
|
|
35
|
+
good := &workflow.Workflow{Name: "goodFlow", Repos: []string{"payments"}, Status: workflow.HealthHealthy, TaskConfig: config.RawValues{"name": "good"}}
|
|
36
|
+
issues := registry.BindWorkflowsIsolated([]*workflow.Workflow{bad, good})
|
|
37
|
+
if len(issues) != 1 || issues[0].Workflow != bad {
|
|
38
|
+
t.Fatalf("binding issues = %#v, want only badFlow", issues)
|
|
39
|
+
}
|
|
40
|
+
if bad.Status != workflow.HealthBlocked || bad.StatusReason == "" {
|
|
41
|
+
t.Fatalf("bad workflow status = %q reason=%q", bad.Status, bad.StatusReason)
|
|
42
|
+
}
|
|
43
|
+
bindings := registered.Bindings()
|
|
44
|
+
if len(bindings) != 1 || bindings[0].Workflow != good {
|
|
45
|
+
t.Fatalf("published bindings = %#v, want only goodFlow", bindings)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
func TestBindWorkflowsIsolatedSkipsOutdatedWorkflow(t *testing.T) {
|
|
50
|
+
registered := &Repo{Name: "payments", TaskSystem: bindingTaskSystem{}}
|
|
51
|
+
registry := NewRegistry()
|
|
52
|
+
registry.Replace(registered)
|
|
53
|
+
wf := &workflow.Workflow{Name: "editedFlow", Repos: []string{"payments"}, Status: workflow.HealthOutdated}
|
|
54
|
+
if issues := registry.BindWorkflowsIsolated([]*workflow.Workflow{wf}); len(issues) != 0 {
|
|
55
|
+
t.Fatalf("outdated workflow binding issues = %#v, want none", issues)
|
|
56
|
+
}
|
|
57
|
+
if bindings := registered.Bindings(); len(bindings) != 0 {
|
|
58
|
+
t.Fatalf("outdated workflow bindings = %#v, want none", bindings)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
func TestBindWorkflowsIsolatedBlocksUnavailableTaskSystem(t *testing.T) {
|
|
63
|
+
registered := &Repo{Name: "payments", TaskSystemError: errors.New("task service offline")}
|
|
64
|
+
registry := NewRegistry()
|
|
65
|
+
registry.Replace(registered)
|
|
66
|
+
wf := &workflow.Workflow{Name: "paymentsFlow", Repos: []string{"payments"}, Status: workflow.HealthHealthy}
|
|
67
|
+
issues := registry.BindWorkflowsIsolated([]*workflow.Workflow{wf})
|
|
68
|
+
if len(issues) != 1 || wf.Status != workflow.HealthBlocked || !strings.Contains(wf.StatusReason, "task service offline") {
|
|
69
|
+
t.Fatalf("unavailable task system issues=%#v status=%q reason=%q", issues, wf.Status, wf.StatusReason)
|
|
70
|
+
}
|
|
71
|
+
if bindings := registered.Bindings(); len(bindings) != 0 {
|
|
72
|
+
t.Fatalf("unavailable repo bindings = %#v, want none", bindings)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
func TestBindWorkflowsIsolatedClearsStaleBindings(t *testing.T) {
|
|
77
|
+
registered := &Repo{Name: "payments", TaskSystem: bindingTaskSystem{}}
|
|
78
|
+
registry := NewRegistry()
|
|
79
|
+
registry.Replace(registered)
|
|
80
|
+
first := &workflow.Workflow{Name: "firstFlow", Repos: []string{"payments"}, Status: workflow.HealthHealthy}
|
|
81
|
+
if issues := registry.BindWorkflowsIsolated([]*workflow.Workflow{first}); len(issues) != 0 {
|
|
82
|
+
t.Fatalf("initial binding issues = %#v", issues)
|
|
83
|
+
}
|
|
84
|
+
second := &workflow.Workflow{Name: "secondFlow", Repos: []string{"payments"}, Status: workflow.HealthHealthy}
|
|
85
|
+
if issues := registry.BindWorkflowsIsolated([]*workflow.Workflow{second}); len(issues) != 0 {
|
|
86
|
+
t.Fatalf("replacement binding issues = %#v", issues)
|
|
87
|
+
}
|
|
88
|
+
bindings := registered.Bindings()
|
|
89
|
+
if len(bindings) != 1 || bindings[0].Workflow != second {
|
|
90
|
+
t.Fatalf("replacement bindings = %#v, want only secondFlow", bindings)
|
|
91
|
+
}
|
|
92
|
+
}
|
package/internal/repo/poller.go
CHANGED
package/internal/repo/repo.go
CHANGED
|
@@ -31,8 +31,12 @@ type Repo struct {
|
|
|
31
31
|
Path string
|
|
32
32
|
TaskConfig config.RawValues
|
|
33
33
|
TaskSystem task.System
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
// TaskSystemError is set when local startup construction could not create
|
|
35
|
+
// a usable adapter. The repo remains visible for diagnostics and repair,
|
|
36
|
+
// while only workflows referencing it are isolated.
|
|
37
|
+
TaskSystemError error
|
|
38
|
+
Workflows []WorkflowBinding
|
|
39
|
+
bindingsMu sync.RWMutex
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
func (r *Repo) Info() Info {
|
|
@@ -95,25 +99,97 @@ func (r *Registry) Remove(name string) {
|
|
|
95
99
|
// BindWorkflows rebuilds the derived Repo.Workflows index from the given
|
|
96
100
|
// workflows. Each repo that a workflow lists gets a binding with the
|
|
97
101
|
// matcher compiled by that repo's task system. Repos not listed by a
|
|
98
|
-
// workflow keep no binding for it.
|
|
102
|
+
// workflow keep no binding for it. The strict form is used by submission,
|
|
103
|
+
// where a binding error must reject the candidate before it is stored.
|
|
99
104
|
func (r *Registry) BindWorkflows(workflows []*workflow.Workflow) error {
|
|
105
|
+
_, err := r.bindWorkflows(workflows, false)
|
|
106
|
+
return err
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// BindingIssue identifies a workflow that could not be safely published.
|
|
110
|
+
// Startup uses the isolated form so one invalid workflow does not prevent
|
|
111
|
+
// unrelated workflows from being bound.
|
|
112
|
+
type BindingIssue struct {
|
|
113
|
+
Workflow *workflow.Workflow
|
|
114
|
+
Error error
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// BindWorkflowsIsolated rebuilds all bindings while isolating workflows whose
|
|
118
|
+
// referenced repo or matcher cannot be built. The returned issues are
|
|
119
|
+
// diagnostics only; valid workflows are still published atomically.
|
|
120
|
+
func (r *Registry) BindWorkflowsIsolated(workflows []*workflow.Workflow) []BindingIssue {
|
|
121
|
+
issues, _ := r.bindWorkflows(workflows, true)
|
|
122
|
+
return issues
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
func (r *Registry) bindWorkflows(workflows []*workflow.Workflow, isolate bool) ([]BindingIssue, error) {
|
|
100
126
|
type binding struct {
|
|
101
127
|
wf *workflow.Workflow
|
|
102
128
|
match func(task.Ticket) bool
|
|
103
129
|
}
|
|
104
130
|
byRepo := map[string][]binding{}
|
|
131
|
+
issues := []BindingIssue{}
|
|
105
132
|
for _, wf := range workflows {
|
|
133
|
+
if wf == nil || !wf.IsRoutable() {
|
|
134
|
+
continue
|
|
135
|
+
}
|
|
136
|
+
failed := false
|
|
106
137
|
for _, repoName := range wf.Repos {
|
|
107
138
|
rp, ok := r.Get(repoName)
|
|
108
139
|
if !ok {
|
|
109
|
-
|
|
140
|
+
err := fmt.Errorf("workflow %q references unregistered repo %q", wf.Name, repoName)
|
|
141
|
+
if !isolate {
|
|
142
|
+
return nil, err
|
|
143
|
+
}
|
|
144
|
+
wf.MarkBlocked(err.Error(), wf.RepairCommand)
|
|
145
|
+
issues = append(issues, BindingIssue{Workflow: wf, Error: err})
|
|
146
|
+
failed = true
|
|
147
|
+
break
|
|
148
|
+
}
|
|
149
|
+
if rp.TaskSystem == nil {
|
|
150
|
+
reason := rp.TaskSystemError
|
|
151
|
+
if reason == nil {
|
|
152
|
+
reason = fmt.Errorf("repo %q task system is unavailable", repoName)
|
|
153
|
+
}
|
|
154
|
+
err := fmt.Errorf("workflow %q repo %q: task system unavailable: %w", wf.Name, repoName, reason)
|
|
155
|
+
if !isolate {
|
|
156
|
+
return nil, err
|
|
157
|
+
}
|
|
158
|
+
wf.MarkBlocked(err.Error(), wf.RepairCommand)
|
|
159
|
+
issues = append(issues, BindingIssue{Workflow: wf, Error: err})
|
|
160
|
+
failed = true
|
|
161
|
+
break
|
|
110
162
|
}
|
|
111
163
|
match, err := rp.TaskSystem.CompileFilter(wf.TaskConfig)
|
|
112
164
|
if err != nil {
|
|
113
|
-
|
|
165
|
+
err = fmt.Errorf("workflow %q repo %q: compile filter: %w", wf.Name, repoName, err)
|
|
166
|
+
if !isolate {
|
|
167
|
+
return nil, err
|
|
168
|
+
}
|
|
169
|
+
wf.MarkBlocked(err.Error(), wf.RepairCommand)
|
|
170
|
+
issues = append(issues, BindingIssue{Workflow: wf, Error: err})
|
|
171
|
+
failed = true
|
|
172
|
+
break
|
|
114
173
|
}
|
|
115
174
|
byRepo[repoName] = append(byRepo[repoName], binding{wf: wf, match: match})
|
|
116
175
|
}
|
|
176
|
+
if failed {
|
|
177
|
+
// A workflow is all-or-nothing across its referenced repositories;
|
|
178
|
+
// never leave a partial route for it.
|
|
179
|
+
for repoName, binds := range byRepo {
|
|
180
|
+
filtered := binds[:0]
|
|
181
|
+
for _, b := range binds {
|
|
182
|
+
if b.wf != wf {
|
|
183
|
+
filtered = append(filtered, b)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if len(filtered) == 0 {
|
|
187
|
+
delete(byRepo, repoName)
|
|
188
|
+
} else {
|
|
189
|
+
byRepo[repoName] = filtered
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
117
193
|
}
|
|
118
194
|
r.mu.Lock()
|
|
119
195
|
defer r.mu.Unlock()
|
|
@@ -128,5 +204,5 @@ func (r *Registry) BindWorkflows(workflows []*workflow.Workflow) error {
|
|
|
128
204
|
rp.Workflows = next
|
|
129
205
|
rp.bindingsMu.Unlock()
|
|
130
206
|
}
|
|
131
|
-
return nil
|
|
207
|
+
return issues, nil
|
|
132
208
|
}
|
package/internal/run/manager.go
CHANGED
|
@@ -64,10 +64,48 @@ func newerRun(candidate, current Run) bool {
|
|
|
64
64
|
// missing claimed run, then ensures the durable run with a value snapshot of
|
|
65
65
|
// the workflow.
|
|
66
66
|
func (m *RunManager) EnsureRun(ctx context.Context, rp *repo.Repo, wf *workflow.Workflow, ticket task.Ticket) error {
|
|
67
|
+
if rp == nil {
|
|
68
|
+
return fmt.Errorf("ensure run: repository is unavailable")
|
|
69
|
+
}
|
|
70
|
+
if rp.TaskSystem == nil {
|
|
71
|
+
if rp.TaskSystemError != nil {
|
|
72
|
+
return fmt.Errorf("ensure run repo %q: task system unavailable: %w", rp.Name, rp.TaskSystemError)
|
|
73
|
+
}
|
|
74
|
+
return fmt.Errorf("ensure run repo %q: task system unavailable", rp.Name)
|
|
75
|
+
}
|
|
76
|
+
if wf == nil {
|
|
77
|
+
return fmt.Errorf("ensure run repo %q: workflow is unavailable", rp.Name)
|
|
78
|
+
}
|
|
67
79
|
if m.Gate != nil {
|
|
68
80
|
m.Gate.Lock()
|
|
69
81
|
defer m.Gate.Unlock()
|
|
70
82
|
}
|
|
83
|
+
// Poll routing resolves a binding before entering this method. Resolve
|
|
84
|
+
// the registry again while holding the same lifecycle gate used by submit
|
|
85
|
+
// and remove so a concurrent replacement cannot create a run from the old
|
|
86
|
+
// workflow snapshot.
|
|
87
|
+
if m.Workflows != nil {
|
|
88
|
+
current, ok := m.Workflows.Get(wf.Name)
|
|
89
|
+
if !ok {
|
|
90
|
+
return fmt.Errorf("ensure run repo %q: workflow %q is no longer stored", rp.Name, wf.Name)
|
|
91
|
+
}
|
|
92
|
+
wf = current
|
|
93
|
+
}
|
|
94
|
+
if !wf.IsRoutable() {
|
|
95
|
+
return fmt.Errorf("ensure run repo %q: workflow %q is %s and cannot start a new run", rp.Name, wf.Name, wf.Status)
|
|
96
|
+
}
|
|
97
|
+
if m.Workflows != nil {
|
|
98
|
+
targeted := false
|
|
99
|
+
for _, repoName := range wf.Repos {
|
|
100
|
+
if repoName == rp.Name {
|
|
101
|
+
targeted = true
|
|
102
|
+
break
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if !targeted {
|
|
106
|
+
return fmt.Errorf("ensure run repo %q: workflow %q no longer targets this repository", rp.Name, wf.Name)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
71
109
|
id := identity.NewRunID(rp.Name, wf.Name, ticket.Key)
|
|
72
110
|
claimed := false
|
|
73
111
|
for _, c := range ticket.WorkflowClaims {
|
|
@@ -214,6 +252,9 @@ func (m *RunManager) RestartByTicket(ctx context.Context, ticket string) (Run, e
|
|
|
214
252
|
if !ok {
|
|
215
253
|
return Run{}, fmt.Errorf("%w: workflow %q for canceled run %s is no longer stored", ErrRestartConflict, previous.Workflow, previous.ID)
|
|
216
254
|
}
|
|
255
|
+
if !wf.IsRoutable() {
|
|
256
|
+
return Run{}, fmt.Errorf("%w: workflow %q is %s and cannot be restarted", ErrRestartConflict, wf.Name, wf.Status)
|
|
257
|
+
}
|
|
217
258
|
bound := false
|
|
218
259
|
for _, name := range wf.Repos {
|
|
219
260
|
if name == previous.Repo {
|
|
@@ -259,6 +259,62 @@ func TestDeterministicRunID(t *testing.T) {
|
|
|
259
259
|
}
|
|
260
260
|
}
|
|
261
261
|
|
|
262
|
+
func TestEnsureRunReResolvesWorkflowUnderLifecycleRegistry(t *testing.T) {
|
|
263
|
+
log := newEventLog()
|
|
264
|
+
sys := &recordingSystem{log: log}
|
|
265
|
+
exec := &fakeExecutor{log: log}
|
|
266
|
+
old := testWorkflow("basicFlow")
|
|
267
|
+
latest := testWorkflow("basicFlow")
|
|
268
|
+
latest.Status = workflow.HealthOutdated
|
|
269
|
+
workflows := &workflow.Registry{}
|
|
270
|
+
workflows.Replace(latest)
|
|
271
|
+
m := &run.RunManager{Executor: exec, Runs: &fakeQueries{}, Workflows: workflows}
|
|
272
|
+
if err := m.EnsureRun(context.Background(), testRepo(sys), old, task.Ticket{Key: "PAY-101"}); err == nil {
|
|
273
|
+
t.Fatal("EnsureRun used stale routable workflow despite registry replacement")
|
|
274
|
+
}
|
|
275
|
+
if len(exec.ensures) != 0 || len(log.all()) != 0 {
|
|
276
|
+
t.Fatalf("stale workflow caused external work: ensures=%d events=%v", len(exec.ensures), log.all())
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
func TestEnsureRunRejectsOutdatedWorkflow(t *testing.T) {
|
|
281
|
+
log := newEventLog()
|
|
282
|
+
sys := &recordingSystem{log: log}
|
|
283
|
+
exec := &fakeExecutor{log: log}
|
|
284
|
+
wf := testWorkflow("basicFlow")
|
|
285
|
+
wf.Status = workflow.HealthOutdated
|
|
286
|
+
m := &run.RunManager{Executor: exec, Runs: &fakeQueries{}}
|
|
287
|
+
if err := m.EnsureRun(context.Background(), testRepo(sys), wf, task.Ticket{Key: "PAY-101"}); err == nil {
|
|
288
|
+
t.Fatal("EnsureRun accepted an outdated workflow")
|
|
289
|
+
}
|
|
290
|
+
if len(exec.ensures) != 0 || len(log.all()) != 0 {
|
|
291
|
+
t.Fatalf("outdated workflow caused external work: ensures=%d events=%v", len(exec.ensures), log.all())
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
func TestRestartByTicketRejectsOutdatedWorkflow(t *testing.T) {
|
|
296
|
+
log := newEventLog()
|
|
297
|
+
sys := &recordingSystem{log: log}
|
|
298
|
+
exec := &fakeExecutor{log: log}
|
|
299
|
+
wf := testWorkflow("basicFlow")
|
|
300
|
+
wf.Status = workflow.HealthOutdated
|
|
301
|
+
repos := repo.NewRegistry()
|
|
302
|
+
repos.Replace(testRepo(sys))
|
|
303
|
+
workflows := &workflow.Registry{}
|
|
304
|
+
workflows.Replace(wf)
|
|
305
|
+
m := &run.RunManager{
|
|
306
|
+
Executor: exec,
|
|
307
|
+
Runs: &fakeQueries{byTicket: map[string]run.Run{"PAY-101": {ID: "old", Repo: "payments", Workflow: "basicFlow", Ticket: task.TicketRef{Key: "PAY-101"}, State: run.StateCanceled}}},
|
|
308
|
+
Repos: repos, Workflows: workflows,
|
|
309
|
+
}
|
|
310
|
+
if _, err := m.RestartByTicket(context.Background(), "PAY-101"); err == nil {
|
|
311
|
+
t.Fatal("RestartByTicket accepted an outdated workflow")
|
|
312
|
+
}
|
|
313
|
+
if len(exec.ensures) != 0 {
|
|
314
|
+
t.Fatalf("outdated restart ensured %d runs", len(exec.ensures))
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
262
318
|
func TestRestartByTicketCreatesNumericFreshAttempt(t *testing.T) {
|
|
263
319
|
log := newEventLog()
|
|
264
320
|
sys := &recordingSystem{log: log}
|
|
@@ -105,5 +105,5 @@ func BuildWorkflowDetail(wf *workflow.Workflow, runs []run.Run) WorkflowDetail {
|
|
|
105
105
|
active++
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
|
-
return WorkflowDetail{Workflow: wf, Valid: wf != nil, ActiveRuns: active, RecentRuns: recent}
|
|
108
|
+
return WorkflowDetail{Workflow: wf, Valid: wf != nil && wf.IsRoutable(), ActiveRuns: active, RecentRuns: recent}
|
|
109
109
|
}
|
|
@@ -105,6 +105,7 @@ func init() {
|
|
|
105
105
|
DefaultConfig: DefaultConfig,
|
|
106
106
|
ValidateTextConfig: validateTextConfig,
|
|
107
107
|
New: newSystem,
|
|
108
|
+
NewLocal: newSystemLocal,
|
|
108
109
|
})
|
|
109
110
|
}
|
|
110
111
|
|
|
@@ -184,6 +185,14 @@ func (s *system) AgentEnv() map[string]string {
|
|
|
184
185
|
// workspace must be supplied by repoConfig; a root-level value never satisfies
|
|
185
186
|
// the required repo-scoped key.
|
|
186
187
|
func beadsTaskScopeKey(rootConfig, repoConfig config.RawValues) (string, error) {
|
|
188
|
+
beadsDir, err := configuredBeadsDir(rootConfig, repoConfig)
|
|
189
|
+
if err != nil {
|
|
190
|
+
return "", err
|
|
191
|
+
}
|
|
192
|
+
return canonicalBeadsDir(beadsDir)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
func configuredBeadsDir(rootConfig, repoConfig config.RawValues) (string, error) {
|
|
187
196
|
var root Config
|
|
188
197
|
if err := config.DecodeStrict(rootConfig, &root); err != nil {
|
|
189
198
|
return "", fmt.Errorf("root task config: %w", err)
|
|
@@ -195,7 +204,7 @@ func beadsTaskScopeKey(rootConfig, repoConfig config.RawValues) (string, error)
|
|
|
195
204
|
if strings.TrimSpace(repo.BeadsDir) == "" {
|
|
196
205
|
return "", errors.New("beads task scope requires repo beadsDir")
|
|
197
206
|
}
|
|
198
|
-
return
|
|
207
|
+
return repo.BeadsDir, nil
|
|
199
208
|
}
|
|
200
209
|
|
|
201
210
|
func canonicalBeadsDir(value string) (string, error) {
|
|
@@ -221,9 +230,31 @@ func canonicalBeadsDir(value string) (string, error) {
|
|
|
221
230
|
return filepath.Clean(resolved), nil
|
|
222
231
|
}
|
|
223
232
|
|
|
233
|
+
func localBeadsDir(rootConfig, repoConfig config.RawValues) (string, error) {
|
|
234
|
+
configured, err := configuredBeadsDir(rootConfig, repoConfig)
|
|
235
|
+
if err != nil {
|
|
236
|
+
return "", err
|
|
237
|
+
}
|
|
238
|
+
abs, err := filepath.Abs(strings.TrimSpace(configured))
|
|
239
|
+
if err != nil {
|
|
240
|
+
return "", fmt.Errorf("resolve beadsDir %q: %w", configured, err)
|
|
241
|
+
}
|
|
242
|
+
return filepath.Clean(abs), nil
|
|
243
|
+
}
|
|
244
|
+
|
|
224
245
|
// newSystem constructs and probes a repo-bound Beads task system. It does not
|
|
225
246
|
// initialize a workspace or start any Beads/Dolt server.
|
|
226
247
|
func newSystem(ctx context.Context, spec task.RepoSpec) (task.System, error) {
|
|
248
|
+
return newSystemWithProbe(ctx, spec, true)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// newSystemLocal constructs only local Beads state. Probe is intentionally
|
|
252
|
+
// deferred to workflow submission so restart can still expose management APIs.
|
|
253
|
+
func newSystemLocal(ctx context.Context, spec task.RepoSpec) (task.System, error) {
|
|
254
|
+
return newSystemWithProbe(ctx, spec, false)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
func newSystemWithProbe(ctx context.Context, spec task.RepoSpec, probe bool) (task.System, error) {
|
|
227
258
|
if strings.TrimSpace(spec.Name) == "" {
|
|
228
259
|
return nil, errors.New("beads: repo name is required")
|
|
229
260
|
}
|
|
@@ -233,7 +264,12 @@ func newSystem(ctx context.Context, spec task.RepoSpec) (task.System, error) {
|
|
|
233
264
|
}
|
|
234
265
|
// Validate the repo-scoped key before merging with root values. This keeps a
|
|
235
266
|
// root beadsDir from silently satisfying repository registration.
|
|
236
|
-
beadsDir
|
|
267
|
+
var beadsDir string
|
|
268
|
+
if probe {
|
|
269
|
+
beadsDir, err = beadsTaskScopeKey(spec.RootConfig, spec.RepoConfig)
|
|
270
|
+
} else {
|
|
271
|
+
beadsDir, err = localBeadsDir(spec.RootConfig, spec.RepoConfig)
|
|
272
|
+
}
|
|
237
273
|
if err != nil {
|
|
238
274
|
return nil, fmt.Errorf("beads repo %q: %w", spec.Name, err)
|
|
239
275
|
}
|
|
@@ -246,8 +282,10 @@ func newSystem(ctx context.Context, spec task.RepoSpec) (task.System, error) {
|
|
|
246
282
|
return nil, fmt.Errorf("beads repo %q config: %w", spec.Name, err)
|
|
247
283
|
}
|
|
248
284
|
cli := bdcli.New(spec.Path, beadsDir)
|
|
249
|
-
if
|
|
250
|
-
|
|
285
|
+
if probe {
|
|
286
|
+
if err := cli.Probe(ctx); err != nil {
|
|
287
|
+
return nil, fmt.Errorf("beads repo %q probe: %w", spec.Name, err)
|
|
288
|
+
}
|
|
251
289
|
}
|
|
252
290
|
return &system{
|
|
253
291
|
cli: cli,
|