relay-flow 0.0.1 → 0.2.0-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 +148 -143
- package/cmd/relay-flow/commands_test.go +464 -0
- package/cmd/relay-flow/main.go +670 -180
- package/cmd/relay-flow/scenario_test.go +1135 -0
- package/cmd/relay-flow/serve.go +609 -0
- package/go.mod +69 -2
- package/go.sum +185 -0
- package/internal/config/config.go +88 -0
- package/internal/config/machine.go +99 -48
- package/internal/config/machine_test.go +248 -0
- package/internal/config/merge_test.go +118 -0
- package/internal/config/writeatomic.go +36 -0
- package/internal/config/writeatomic_test.go +98 -0
- package/internal/execution/goworkflows/activities.go +490 -0
- package/internal/execution/goworkflows/engine.go +487 -0
- package/internal/execution/goworkflows/engine_test.go +600 -0
- package/internal/execution/goworkflows/fakes_test.go +517 -0
- package/internal/execution/goworkflows/interpreter.go +605 -0
- package/internal/execution/goworkflows/logging_test.go +154 -0
- package/internal/execution/goworkflows/mailbox_test.go +423 -0
- package/internal/execution/goworkflows/node_runtime_integration_test.go +127 -0
- package/internal/execution/goworkflows/node_runtime_test.go +486 -0
- package/internal/execution/goworkflows/projection.go +504 -0
- package/internal/execution/goworkflows/recovery_test.go +1092 -0
- package/internal/execution/goworkflows/retry_log_test.go +59 -0
- package/internal/execution/goworkflows/retry_projection_test.go +98 -0
- package/internal/harness/contract_test.go +169 -0
- package/internal/harness/factory.go +63 -0
- package/internal/harness/harness.go +41 -0
- package/internal/harness/opencode/opencode.go +166 -0
- package/internal/harness/opencode/opencode_test.go +50 -0
- package/internal/harness/plugin_selection_test.go +126 -0
- package/internal/identity/identity.go +37 -0
- package/internal/logging/logging.go +56 -0
- package/internal/logging/logging_test.go +116 -0
- package/internal/paths/paths.go +67 -0
- package/internal/recover/recover.go +115 -0
- package/internal/repo/poller.go +186 -0
- package/internal/repo/poller_test.go +327 -0
- package/internal/repo/repo.go +119 -0
- package/internal/repo/service.go +216 -0
- package/internal/repo/service_test.go +298 -0
- package/internal/retry/retry.go +118 -0
- package/internal/router/router.go +83 -0
- package/internal/router/router_test.go +144 -0
- package/internal/run/manager.go +108 -0
- package/internal/run/run.go +140 -0
- package/internal/run/run_identity_test.go +52 -0
- package/internal/run/run_manager_test.go +266 -0
- package/internal/runner/contract_test.go +221 -0
- package/internal/runner/factory.go +65 -0
- package/internal/runner/orca/orca.go +363 -170
- package/internal/runner/orca/orca_test.go +134 -160
- package/internal/runner/orca/orcacli/orcacli.go +215 -0
- package/internal/runner/orca/orcacli/orcacli_test.go +154 -0
- package/internal/runner/orca/orcacli/testdata/repo-list.json +18 -0
- package/internal/runner/orca/orcacli/testdata/strict-orca.sh +30 -0
- package/internal/runner/orca/orcacli/testdata/terminal-close.json +12 -0
- package/internal/runner/orca/orcacli/testdata/terminal-create.json +18 -0
- package/internal/runner/orca/orcacli/testdata/terminal-list.json +51 -0
- package/internal/runner/orca/orcacli/testdata/terminal-send.json +1 -0
- package/internal/runner/orca/orcacli/testdata/terminal-show.json +1 -0
- package/internal/runner/orca/orcacli/testdata/worktree-create.json +22 -0
- package/internal/runner/orca/orcacli/testdata/worktree-list.json +31 -0
- package/internal/runner/orca/orcacli/testdata/worktree-remove.json +6 -0
- package/internal/runner/runner.go +47 -64
- package/internal/server/api_test.go +300 -0
- package/internal/server/client.go +192 -74
- package/internal/server/fixture_test.go +248 -0
- package/internal/server/server.go +425 -248
- package/internal/server/shutdown_test.go +116 -0
- package/internal/task/contract_test.go +223 -0
- package/internal/task/factory.go +103 -0
- package/internal/task/jira/acli/acli.go +306 -0
- package/internal/task/jira/acli/acli_test.go +208 -0
- package/internal/task/jira/acli/testdata/acli_comments.json +55 -0
- package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +1 -0
- package/internal/task/jira/acli/testdata/search_invalid_status.txt +1 -0
- package/internal/task/jira/acli/testdata/search_success.json +1 -0
- package/internal/task/jira/filters_test.go +234 -0
- package/internal/task/jira/helpers_test.go +60 -0
- package/internal/task/jira/jira.go +507 -0
- package/internal/task/jira/normalize.go +101 -0
- package/internal/task/jira/testdata/acli_search.json +120 -0
- package/internal/task/jira/transition_defaults_test.go +156 -0
- package/internal/task/jira/validation_test.go +94 -0
- package/internal/task/task.go +84 -0
- package/internal/workflow/report.go +85 -0
- package/internal/workflow/report_test.go +259 -0
- package/internal/workflow/service.go +142 -0
- package/internal/workflow/store.go +136 -0
- package/internal/workflow/store_test.go +282 -0
- package/internal/workflow/workflow.go +342 -0
- package/internal/workflow/workflow_test.go +410 -0
- package/package.json +1 -1
- package/internal/acli/acli.go +0 -229
- package/internal/config/demo_test.go +0 -17
- package/internal/config/schema.go +0 -193
- package/internal/config/schema_test.go +0 -162
- package/internal/daemon/daemon.go +0 -218
- package/internal/daemon/daemon_test.go +0 -204
- package/internal/discovery/discovery.go +0 -122
- package/internal/discovery/discovery_test.go +0 -62
- package/internal/opencode/opencode.go +0 -26
- package/internal/orcacli/orcacli.go +0 -264
- package/internal/runner/orca/README.md +0 -64
- package/internal/runner/runner_test.go +0 -64
- package/internal/server/server_test.go +0 -195
- package/internal/tasks/jira/README.md +0 -69
- package/internal/tasks/jira/component_test.go +0 -16
- package/internal/tasks/jira/decode.go +0 -24
- package/internal/tasks/jira/jira.go +0 -231
- package/internal/tasks/jira/jira_test.go +0 -259
- package/internal/tasks/jira/jql_test.go +0 -16
- package/internal/tasks/tasks.go +0 -90
- package/internal/tasks/tasks_test.go +0 -91
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Package router is the pure Ticket Router. It has no Jira, SQLite, runner,
|
|
2
|
+
// or goroutine dependency.
|
|
3
|
+
package router
|
|
4
|
+
|
|
5
|
+
import (
|
|
6
|
+
"errors"
|
|
7
|
+
"fmt"
|
|
8
|
+
"sort"
|
|
9
|
+
"strings"
|
|
10
|
+
|
|
11
|
+
"github.com/rajpopat27/relay-flow/internal/repo"
|
|
12
|
+
"github.com/rajpopat27/relay-flow/internal/task"
|
|
13
|
+
"github.com/rajpopat27/relay-flow/internal/workflow"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
// ErrNoMatch means an unclaimed ticket matches no workflow filter; the
|
|
17
|
+
// polling handler ignores it.
|
|
18
|
+
var ErrNoMatch = errors.New("ticket matches no workflow")
|
|
19
|
+
|
|
20
|
+
// AmbiguousError means an unclaimed ticket matches more than one workflow.
|
|
21
|
+
// No ticket mutation occurs.
|
|
22
|
+
type AmbiguousError struct {
|
|
23
|
+
Ticket string
|
|
24
|
+
Workflows []string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
func (e *AmbiguousError) Error() string {
|
|
28
|
+
return fmt.Sprintf("ticket %s matches multiple workflows: %s", e.Ticket, strings.Join(e.Workflows, ", "))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// InvalidClaimError means the ticket carries several workflow claims, or a
|
|
32
|
+
// claim naming an unknown workflow or one not registered for the repo.
|
|
33
|
+
type InvalidClaimError struct {
|
|
34
|
+
Ticket string
|
|
35
|
+
Workflow string
|
|
36
|
+
Repo string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
func (e *InvalidClaimError) Error() string {
|
|
40
|
+
return fmt.Sprintf("ticket %s has invalid workflow claim %q for repo %s", e.Ticket, e.Workflow, e.Repo)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const claimPrefix = "wf:"
|
|
44
|
+
|
|
45
|
+
// ResolveWorkflow routes a ticket to exactly one workflow following the
|
|
46
|
+
// deterministic routing order: multiple claims are invalid; a single claim
|
|
47
|
+
// resolves directly from repo bindings without re-running filters; an
|
|
48
|
+
// unknown or unbound claim is invalid; otherwise precompiled matchers run —
|
|
49
|
+
// zero matches is ErrNoMatch, one match wins, several is ambiguous.
|
|
50
|
+
func ResolveWorkflow(registered *repo.Repo, ticket task.Ticket) (*workflow.Workflow, error) {
|
|
51
|
+
claims := ticket.WorkflowClaims
|
|
52
|
+
if len(claims) > 1 {
|
|
53
|
+
return nil, &InvalidClaimError{Ticket: ticket.Key, Workflow: strings.Join(claims, ","), Repo: registered.Name}
|
|
54
|
+
}
|
|
55
|
+
if len(claims) == 1 {
|
|
56
|
+
name := strings.TrimPrefix(claims[0], claimPrefix)
|
|
57
|
+
for _, b := range registered.Workflows {
|
|
58
|
+
if b.Workflow.Name == name {
|
|
59
|
+
return b.Workflow, nil
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return nil, &InvalidClaimError{Ticket: ticket.Key, Workflow: name, Repo: registered.Name}
|
|
63
|
+
}
|
|
64
|
+
var matched []*workflow.Workflow
|
|
65
|
+
for _, b := range registered.Workflows {
|
|
66
|
+
if b.Match != nil && b.Match(ticket) {
|
|
67
|
+
matched = append(matched, b.Workflow)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
switch len(matched) {
|
|
71
|
+
case 0:
|
|
72
|
+
return nil, ErrNoMatch
|
|
73
|
+
case 1:
|
|
74
|
+
return matched[0], nil
|
|
75
|
+
default:
|
|
76
|
+
names := make([]string, 0, len(matched))
|
|
77
|
+
for _, wf := range matched {
|
|
78
|
+
names = append(names, wf.Name)
|
|
79
|
+
}
|
|
80
|
+
sort.Strings(names)
|
|
81
|
+
return nil, &AmbiguousError{Ticket: ticket.Key, Workflows: names}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
package router_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"errors"
|
|
5
|
+
"testing"
|
|
6
|
+
|
|
7
|
+
"github.com/rajpopat27/relay-flow/internal/repo"
|
|
8
|
+
"github.com/rajpopat27/relay-flow/internal/router"
|
|
9
|
+
"github.com/rajpopat27/relay-flow/internal/task"
|
|
10
|
+
"github.com/rajpopat27/relay-flow/internal/workflow"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
// 3.11: Ticket Router tests (pure) per specs/repo-workflow-routing
|
|
14
|
+
// "Existing workflow claims take precedence" and "Unclaimed routing is
|
|
15
|
+
// unambiguous", following design decision 6's exact routing order.
|
|
16
|
+
|
|
17
|
+
func wf(name string) *workflow.Workflow {
|
|
18
|
+
return &workflow.Workflow{
|
|
19
|
+
Name: name,
|
|
20
|
+
Repos: []string{"payments"},
|
|
21
|
+
Nodes: map[string]workflow.Node{
|
|
22
|
+
"start": {OnSuccess: []workflow.Route{{Target: "coding"}}},
|
|
23
|
+
"coding": {
|
|
24
|
+
Type: workflow.NodeAgent, Agent: "build", Description: "work",
|
|
25
|
+
OnSuccess: []workflow.Route{{Target: "end"}},
|
|
26
|
+
OnFailure: []workflow.Route{{Target: "coding"}},
|
|
27
|
+
},
|
|
28
|
+
"end": {},
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
func binding(w *workflow.Workflow, match func(task.Ticket) bool) repo.WorkflowBinding {
|
|
34
|
+
return repo.WorkflowBinding{Workflow: w, Match: match}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
func repoWith(bindings ...repo.WorkflowBinding) *repo.Repo {
|
|
38
|
+
return &repo.Repo{Name: "payments", Path: "/srv/payments", Workflows: bindings}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
func TestMultipleClaimsInvalid(t *testing.T) {
|
|
42
|
+
r := repoWith(binding(wf("aFlow"), nil), binding(wf("bFlow"), nil))
|
|
43
|
+
ticket := task.Ticket{Key: "PAY-101", WorkflowClaims: []string{"wf:aFlow", "wf:bFlow"}}
|
|
44
|
+
|
|
45
|
+
_, err := router.ResolveWorkflow(r, ticket)
|
|
46
|
+
var ice *router.InvalidClaimError
|
|
47
|
+
if !errors.As(err, &ice) {
|
|
48
|
+
t.Fatalf("err = %v, want InvalidClaimError", err)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
func TestSingleClaimResolvesDirectly(t *testing.T) {
|
|
53
|
+
// Filters must NOT be re-run for a claimed ticket: the matcher would
|
|
54
|
+
// reject this ticket, and the claim still wins.
|
|
55
|
+
matchCalled := false
|
|
56
|
+
r := repoWith(binding(wf("basicFlow"), func(task.Ticket) bool {
|
|
57
|
+
matchCalled = true
|
|
58
|
+
return false
|
|
59
|
+
}))
|
|
60
|
+
ticket := task.Ticket{Key: "PAY-101", WorkflowClaims: []string{"wf:basicFlow"}}
|
|
61
|
+
|
|
62
|
+
got, err := router.ResolveWorkflow(r, ticket)
|
|
63
|
+
if err != nil {
|
|
64
|
+
t.Fatalf("ResolveWorkflow failed: %v", err)
|
|
65
|
+
}
|
|
66
|
+
if got.Name != "basicFlow" {
|
|
67
|
+
t.Fatalf("resolved %q, want basicFlow", got.Name)
|
|
68
|
+
}
|
|
69
|
+
if matchCalled {
|
|
70
|
+
t.Fatal("matcher re-evaluated for a singly claimed ticket; claim resolves directly")
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
func TestUnknownClaimInvalid(t *testing.T) {
|
|
75
|
+
r := repoWith(binding(wf("basicFlow"), nil))
|
|
76
|
+
ticket := task.Ticket{Key: "PAY-101", WorkflowClaims: []string{"wf:ghost"}}
|
|
77
|
+
|
|
78
|
+
_, err := router.ResolveWorkflow(r, ticket)
|
|
79
|
+
var ice *router.InvalidClaimError
|
|
80
|
+
if !errors.As(err, &ice) {
|
|
81
|
+
t.Fatalf("err = %v, want InvalidClaimError for unknown claim", err)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
func TestClaimForWorkflowNotBoundToRepo(t *testing.T) {
|
|
86
|
+
// otherFlow exists but targets another repo, so it is not in this
|
|
87
|
+
// repo's bindings.
|
|
88
|
+
r := repoWith(binding(wf("basicFlow"), nil))
|
|
89
|
+
ticket := task.Ticket{Key: "PAY-101", WorkflowClaims: []string{"wf:otherFlow"}}
|
|
90
|
+
|
|
91
|
+
_, err := router.ResolveWorkflow(r, ticket)
|
|
92
|
+
var ice *router.InvalidClaimError
|
|
93
|
+
if !errors.As(err, &ice) {
|
|
94
|
+
t.Fatalf("err = %v, want InvalidClaimError for unbound claim", err)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
func TestZeroMatchesIgnored(t *testing.T) {
|
|
99
|
+
r := repoWith(binding(wf("basicFlow"), func(task.Ticket) bool { return false }))
|
|
100
|
+
ticket := task.Ticket{Key: "PAY-101"}
|
|
101
|
+
|
|
102
|
+
_, err := router.ResolveWorkflow(r, ticket)
|
|
103
|
+
if !errors.Is(err, router.ErrNoMatch) {
|
|
104
|
+
t.Fatalf("err = %v, want ErrNoMatch", err)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
func TestOneMatchSelected(t *testing.T) {
|
|
109
|
+
r := repoWith(
|
|
110
|
+
binding(wf("basicFlow"), func(task.Ticket) bool { return true }),
|
|
111
|
+
binding(wf("otherFlow"), func(task.Ticket) bool { return false }),
|
|
112
|
+
)
|
|
113
|
+
ticket := task.Ticket{Key: "PAY-101"}
|
|
114
|
+
|
|
115
|
+
got, err := router.ResolveWorkflow(r, ticket)
|
|
116
|
+
if err != nil {
|
|
117
|
+
t.Fatalf("ResolveWorkflow failed: %v", err)
|
|
118
|
+
}
|
|
119
|
+
if got.Name != "basicFlow" {
|
|
120
|
+
t.Fatalf("resolved %q, want basicFlow", got.Name)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
func TestMultipleMatchesAmbiguous(t *testing.T) {
|
|
125
|
+
r := repoWith(
|
|
126
|
+
binding(wf("basicFlow"), func(task.Ticket) bool { return true }),
|
|
127
|
+
binding(wf("otherFlow"), func(task.Ticket) bool { return true }),
|
|
128
|
+
)
|
|
129
|
+
ticket := task.Ticket{Key: "PAY-101", WorkflowClaims: nil}
|
|
130
|
+
|
|
131
|
+
before := ticket
|
|
132
|
+
_, err := router.ResolveWorkflow(r, ticket)
|
|
133
|
+
var amb *router.AmbiguousError
|
|
134
|
+
if !errors.As(err, &amb) {
|
|
135
|
+
t.Fatalf("err = %v, want AmbiguousError", err)
|
|
136
|
+
}
|
|
137
|
+
if len(amb.Workflows) != 2 {
|
|
138
|
+
t.Fatalf("AmbiguousError.Workflows = %v, want both workflows", amb.Workflows)
|
|
139
|
+
}
|
|
140
|
+
// No mutation on ambiguity.
|
|
141
|
+
if ticket.WorkflowClaims != nil || ticket.Key != before.Key {
|
|
142
|
+
t.Fatalf("ticket mutated on ambiguity: %+v", ticket)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
package run
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"fmt"
|
|
6
|
+
"log/slog"
|
|
7
|
+
"sync"
|
|
8
|
+
|
|
9
|
+
"github.com/rajpopat27/relay-flow/internal/identity"
|
|
10
|
+
"github.com/rajpopat27/relay-flow/internal/repo"
|
|
11
|
+
"github.com/rajpopat27/relay-flow/internal/task"
|
|
12
|
+
"github.com/rajpopat27/relay-flow/internal/workflow"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
// CancellationMarker is the stable parent comment marker recording that the
|
|
16
|
+
// run was canceled; a missing claimed run carrying it is never recreated.
|
|
17
|
+
func CancellationMarker(id ID) string {
|
|
18
|
+
return string(id) + ":cancellation"
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// RunManager performs only assignment and durable-run creation.
|
|
22
|
+
type RunManager struct {
|
|
23
|
+
Executor Executor
|
|
24
|
+
Runs RunQueries
|
|
25
|
+
// Gate, when non-nil, is the lifecycle mutex shared with
|
|
26
|
+
// workflow.Service.Submit/Remove (design.md decision 23): run creation
|
|
27
|
+
// holds it from final workflow resolution through claim + EnsureRun so
|
|
28
|
+
// a run never starts against a workflow definition that is concurrently
|
|
29
|
+
// being replaced or removed. A plain *sync.Mutex — no lock service.
|
|
30
|
+
Gate *sync.Mutex
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// EnsureRun claims the ticket if unassigned, skips claiming when the ticket
|
|
34
|
+
// is already assigned to this workflow, checks the stable cancellation
|
|
35
|
+
// marker before recreating a missing claimed run, then ensures the durable
|
|
36
|
+
// run with a value snapshot of the workflow.
|
|
37
|
+
func (m *RunManager) EnsureRun(ctx context.Context, rp *repo.Repo, wf *workflow.Workflow, ticket task.Ticket) error {
|
|
38
|
+
if m.Gate != nil {
|
|
39
|
+
m.Gate.Lock()
|
|
40
|
+
defer m.Gate.Unlock()
|
|
41
|
+
}
|
|
42
|
+
id := identity.NewRunID(rp.Name, wf.Name, ticket.Key)
|
|
43
|
+
claimed := false
|
|
44
|
+
for _, c := range ticket.WorkflowClaims {
|
|
45
|
+
if c == "wf:"+wf.Name {
|
|
46
|
+
claimed = true
|
|
47
|
+
break
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if !claimed {
|
|
51
|
+
if err := rp.TaskSystem.Claim(ctx, ticket.Ref(), wf.Name); err != nil {
|
|
52
|
+
slog.Info("ensure-run outcome",
|
|
53
|
+
"ticket", ticket.Key, "repo", rp.Name, "workflow", wf.Name, "runID", string(id),
|
|
54
|
+
"outcome", "error", "stage", "claim", "error", err)
|
|
55
|
+
return fmt.Errorf("claim %s for workflow %s: %w", ticket.Key, wf.Name, err)
|
|
56
|
+
}
|
|
57
|
+
} else {
|
|
58
|
+
// Claimed but possibly missing its run (claim-before-run crash gap or
|
|
59
|
+
// retention cleanup): never recreate a canceled run.
|
|
60
|
+
marked, err := rp.TaskSystem.HasComment(ctx, task.Target{Parent: ticket.Ref()}, CancellationMarker(id))
|
|
61
|
+
if err != nil {
|
|
62
|
+
slog.Info("ensure-run outcome",
|
|
63
|
+
"ticket", ticket.Key, "repo", rp.Name, "workflow", wf.Name, "runID", string(id),
|
|
64
|
+
"outcome", "error", "stage", "cancellation-marker", "error", err)
|
|
65
|
+
return fmt.Errorf("check cancellation marker on %s: %w", ticket.Key, err)
|
|
66
|
+
}
|
|
67
|
+
if marked {
|
|
68
|
+
slog.Info("ensure-run outcome",
|
|
69
|
+
"ticket", ticket.Key, "repo", rp.Name, "workflow", wf.Name, "runID", string(id),
|
|
70
|
+
"outcome", "skipped-cancellation-marker")
|
|
71
|
+
return nil
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
created, err := m.Executor.EnsureRun(ctx, Start{
|
|
75
|
+
ID: id,
|
|
76
|
+
Repo: rp.Name,
|
|
77
|
+
RepoPath: rp.Path,
|
|
78
|
+
Workflow: *wf,
|
|
79
|
+
Ticket: ticket.Ref(),
|
|
80
|
+
})
|
|
81
|
+
if err != nil {
|
|
82
|
+
slog.Info("ensure-run outcome",
|
|
83
|
+
"ticket", ticket.Key, "repo", rp.Name, "workflow", wf.Name, "runID", string(id),
|
|
84
|
+
"outcome", "error", "stage", "executor", "error", err)
|
|
85
|
+
return fmt.Errorf("ensure run %s: %w", id, err)
|
|
86
|
+
}
|
|
87
|
+
outcome := "exists"
|
|
88
|
+
if created {
|
|
89
|
+
outcome = "created"
|
|
90
|
+
}
|
|
91
|
+
slog.Info("ensure-run outcome",
|
|
92
|
+
"ticket", ticket.Key, "repo", rp.Name, "workflow", wf.Name, "runID", string(id),
|
|
93
|
+
"outcome", outcome)
|
|
94
|
+
return nil
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// CancelByTicket resolves the active run through FindRunByTicket, then
|
|
98
|
+
// calls Executor.CancelRun.
|
|
99
|
+
func (m *RunManager) CancelByTicket(ctx context.Context, ticket, reason string) error {
|
|
100
|
+
r, err := m.Runs.FindRunByTicket(ctx, ticket)
|
|
101
|
+
if err != nil {
|
|
102
|
+
return fmt.Errorf("find run for ticket %s: %w", ticket, err)
|
|
103
|
+
}
|
|
104
|
+
if err := m.Executor.CancelRun(ctx, r.ID, reason); err != nil {
|
|
105
|
+
return fmt.Errorf("cancel run %s: %w", r.ID, err)
|
|
106
|
+
}
|
|
107
|
+
return nil
|
|
108
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Package run defines run IDs/state, orchestration values, the durable
|
|
2
|
+
// Executor boundary, run queries, and the Run Manager.
|
|
3
|
+
package run
|
|
4
|
+
|
|
5
|
+
import (
|
|
6
|
+
"context"
|
|
7
|
+
"time"
|
|
8
|
+
|
|
9
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
10
|
+
"github.com/rajpopat27/relay-flow/internal/identity"
|
|
11
|
+
"github.com/rajpopat27/relay-flow/internal/task"
|
|
12
|
+
"github.com/rajpopat27/relay-flow/internal/workflow"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
type ID = identity.RunID
|
|
16
|
+
type NodeVisitID = identity.NodeVisitID
|
|
17
|
+
|
|
18
|
+
type State string
|
|
19
|
+
|
|
20
|
+
const (
|
|
21
|
+
StateStarting State = "starting"
|
|
22
|
+
StateRunning State = "running"
|
|
23
|
+
StateWaiting State = "waiting"
|
|
24
|
+
StateBlocked State = "blocked"
|
|
25
|
+
StateCompleted State = "completed"
|
|
26
|
+
StateCanceling State = "canceling"
|
|
27
|
+
StateCanceled State = "canceled"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
// Start carries the immutable value snapshot of the accepted workflow for
|
|
31
|
+
// deterministic replay. The interpreter consumes only this snapshot.
|
|
32
|
+
type Start struct {
|
|
33
|
+
ID ID `json:"id"`
|
|
34
|
+
Repo string `json:"repo"`
|
|
35
|
+
RepoPath string `json:"repoPath"`
|
|
36
|
+
Workflow workflow.Workflow `json:"workflow"`
|
|
37
|
+
Ticket task.TicketRef `json:"ticket"`
|
|
38
|
+
Runtime RuntimePolicy `json:"runtime"`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type RuntimePolicy struct {
|
|
42
|
+
KeepTerminalsAlive bool `json:"keepTerminalsAlive"`
|
|
43
|
+
KeepSessionsAlive bool `json:"keepSessionsAlive"`
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type Work struct {
|
|
47
|
+
RunID ID
|
|
48
|
+
Repo string
|
|
49
|
+
Workflow string
|
|
50
|
+
Parent task.TicketRef
|
|
51
|
+
WorkflowTaskConfig config.RawValues
|
|
52
|
+
Runtime RuntimePolicy
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
type NodeWork struct {
|
|
56
|
+
Work
|
|
57
|
+
Node string
|
|
58
|
+
NodeVisitID NodeVisitID
|
|
59
|
+
Mailbox task.Mailbox
|
|
60
|
+
NodeTaskConfig config.RawValues
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
type CommentWork struct {
|
|
64
|
+
// RunID is the enclosing durable run; the comment activity uses it to
|
|
65
|
+
// attribute its 9.3 transition log lines (ticket/runID/node attrs).
|
|
66
|
+
RunID ID
|
|
67
|
+
Item task.Target
|
|
68
|
+
Body string
|
|
69
|
+
Marker string
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// RetryStatus describes the currently pending durable activity retry without
|
|
73
|
+
// replacing the run's lifecycle state.
|
|
74
|
+
type RetryStatus struct {
|
|
75
|
+
Attempt int `json:"attempt"`
|
|
76
|
+
LastError string `json:"lastError"`
|
|
77
|
+
NextRetryAt time.Time `json:"nextRetryAt"`
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
type Run struct {
|
|
81
|
+
ID ID `json:"id"`
|
|
82
|
+
Repo string `json:"repo"`
|
|
83
|
+
Workflow string `json:"workflow"`
|
|
84
|
+
Ticket task.TicketRef `json:"ticket"`
|
|
85
|
+
State State `json:"state"`
|
|
86
|
+
CurrentNode string `json:"currentNode,omitempty"`
|
|
87
|
+
CurrentNodeVisitID NodeVisitID `json:"currentNodeVisitId,omitempty"`
|
|
88
|
+
LastError string `json:"lastError,omitempty"`
|
|
89
|
+
Retry *RetryStatus `json:"retry,omitempty"`
|
|
90
|
+
StartedAt time.Time `json:"startedAt"`
|
|
91
|
+
UpdatedAt time.Time `json:"updatedAt"`
|
|
92
|
+
FinishedAt *time.Time `json:"finishedAt,omitempty"`
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
type ReportRequest struct {
|
|
96
|
+
RunID ID `json:"runId"`
|
|
97
|
+
Node string `json:"node"`
|
|
98
|
+
ReportID string `json:"reportId"`
|
|
99
|
+
Report workflow.Report `json:"report"`
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
type ReportAck struct {
|
|
103
|
+
Accepted bool `json:"accepted"`
|
|
104
|
+
Duplicate bool `json:"duplicate"`
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// NodeRuntimeRegistration binds the OpenCode session emitted for one run/node.
|
|
108
|
+
type NodeRuntimeRegistration struct {
|
|
109
|
+
RunID ID `json:"runId"`
|
|
110
|
+
Node string `json:"node"`
|
|
111
|
+
SessionID string `json:"sessionId"`
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
type NodeRuntimeRegistrationAck struct {
|
|
115
|
+
Accepted bool `json:"accepted"`
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
type Filter struct {
|
|
119
|
+
Repo string
|
|
120
|
+
Workflow string
|
|
121
|
+
Ticket string
|
|
122
|
+
Active *bool
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Executor is the replacement boundary for go-workflows, Temporal, or
|
|
126
|
+
// another durable engine. No go-workflows context, instance, backend,
|
|
127
|
+
// queue, or error crosses this interface.
|
|
128
|
+
type Executor interface {
|
|
129
|
+
EnsureRun(ctx context.Context, start Start) (created bool, err error)
|
|
130
|
+
SubmitReport(ctx context.Context, report ReportRequest) (ReportAck, error)
|
|
131
|
+
CancelRun(ctx context.Context, id ID, reason string) error
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
type RunQueries interface {
|
|
135
|
+
GetRun(ctx context.Context, id ID) (Run, error)
|
|
136
|
+
FindRunByTicket(ctx context.Context, ticket string) (Run, error)
|
|
137
|
+
ListRuns(ctx context.Context, filter Filter) ([]Run, error)
|
|
138
|
+
HasActiveWorkflow(ctx context.Context, workflow string) (bool, error)
|
|
139
|
+
HasActiveRepo(ctx context.Context, repo string) (bool, error)
|
|
140
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
package run_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"testing"
|
|
5
|
+
|
|
6
|
+
"github.com/rajpopat27/relay-flow/internal/identity"
|
|
7
|
+
"github.com/rajpopat27/relay-flow/internal/run"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
// 3.14: run identity per specs/durable-run-execution "Each parent ticket
|
|
11
|
+
// has one durable run" and "Every node entry has a distinct visit identity".
|
|
12
|
+
|
|
13
|
+
func TestRunIDDeterministic(t *testing.T) {
|
|
14
|
+
a := identity.NewRunID("payments", "basicFlow", "PAY-101")
|
|
15
|
+
b := identity.NewRunID("payments", "basicFlow", "PAY-101")
|
|
16
|
+
if a != b {
|
|
17
|
+
t.Fatalf("run ID not deterministic: %q vs %q", a, b)
|
|
18
|
+
}
|
|
19
|
+
c := identity.NewRunID("payments", "basicFlow", "PAY-102")
|
|
20
|
+
if a == c {
|
|
21
|
+
t.Fatal("different tickets produced the same run ID")
|
|
22
|
+
}
|
|
23
|
+
d := identity.NewRunID("other", "basicFlow", "PAY-101")
|
|
24
|
+
if a == d {
|
|
25
|
+
t.Fatal("different repos produced the same run ID")
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
func TestRunIDDelimiterSafe(t *testing.T) {
|
|
30
|
+
// Components containing delimiters must not collide.
|
|
31
|
+
a := identity.NewRunID("pay/ments", "basicFlow", "PAY-101")
|
|
32
|
+
b := identity.NewRunID("pay", "ments/basicFlow", "PAY-101")
|
|
33
|
+
if a == b {
|
|
34
|
+
t.Fatalf("delimiter collision: %q == %q", a, b)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
func TestNodeVisitIDsUniquePerEntry(t *testing.T) {
|
|
39
|
+
first := identity.NewNodeVisitID()
|
|
40
|
+
second := identity.NewNodeVisitID()
|
|
41
|
+
if first == second {
|
|
42
|
+
t.Fatal("two node entries generated the same nodeVisitID")
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// EnsureRun idempotence is covered with the RunManager tests (3.12) using a
|
|
47
|
+
// fake executor: a repeated EnsureRun with the same deterministic ID
|
|
48
|
+
// returns the existing run without restarting. The durable side-effect
|
|
49
|
+
// stability of nodeVisitID across replay is an engine-level behavior tested
|
|
50
|
+
// in internal/execution/goworkflows.
|
|
51
|
+
|
|
52
|
+
var _ = run.ID("")
|