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.
Files changed (116) hide show
  1. package/README.md +148 -143
  2. package/cmd/relay-flow/commands_test.go +464 -0
  3. package/cmd/relay-flow/main.go +670 -180
  4. package/cmd/relay-flow/scenario_test.go +1135 -0
  5. package/cmd/relay-flow/serve.go +609 -0
  6. package/go.mod +69 -2
  7. package/go.sum +185 -0
  8. package/internal/config/config.go +88 -0
  9. package/internal/config/machine.go +99 -48
  10. package/internal/config/machine_test.go +248 -0
  11. package/internal/config/merge_test.go +118 -0
  12. package/internal/config/writeatomic.go +36 -0
  13. package/internal/config/writeatomic_test.go +98 -0
  14. package/internal/execution/goworkflows/activities.go +490 -0
  15. package/internal/execution/goworkflows/engine.go +487 -0
  16. package/internal/execution/goworkflows/engine_test.go +600 -0
  17. package/internal/execution/goworkflows/fakes_test.go +517 -0
  18. package/internal/execution/goworkflows/interpreter.go +605 -0
  19. package/internal/execution/goworkflows/logging_test.go +154 -0
  20. package/internal/execution/goworkflows/mailbox_test.go +423 -0
  21. package/internal/execution/goworkflows/node_runtime_integration_test.go +127 -0
  22. package/internal/execution/goworkflows/node_runtime_test.go +486 -0
  23. package/internal/execution/goworkflows/projection.go +504 -0
  24. package/internal/execution/goworkflows/recovery_test.go +1092 -0
  25. package/internal/execution/goworkflows/retry_log_test.go +59 -0
  26. package/internal/execution/goworkflows/retry_projection_test.go +98 -0
  27. package/internal/harness/contract_test.go +169 -0
  28. package/internal/harness/factory.go +63 -0
  29. package/internal/harness/harness.go +41 -0
  30. package/internal/harness/opencode/opencode.go +166 -0
  31. package/internal/harness/opencode/opencode_test.go +50 -0
  32. package/internal/harness/plugin_selection_test.go +126 -0
  33. package/internal/identity/identity.go +37 -0
  34. package/internal/logging/logging.go +56 -0
  35. package/internal/logging/logging_test.go +116 -0
  36. package/internal/paths/paths.go +67 -0
  37. package/internal/recover/recover.go +115 -0
  38. package/internal/repo/poller.go +186 -0
  39. package/internal/repo/poller_test.go +327 -0
  40. package/internal/repo/repo.go +119 -0
  41. package/internal/repo/service.go +216 -0
  42. package/internal/repo/service_test.go +298 -0
  43. package/internal/retry/retry.go +118 -0
  44. package/internal/router/router.go +83 -0
  45. package/internal/router/router_test.go +144 -0
  46. package/internal/run/manager.go +108 -0
  47. package/internal/run/run.go +140 -0
  48. package/internal/run/run_identity_test.go +52 -0
  49. package/internal/run/run_manager_test.go +266 -0
  50. package/internal/runner/contract_test.go +221 -0
  51. package/internal/runner/factory.go +65 -0
  52. package/internal/runner/orca/orca.go +363 -170
  53. package/internal/runner/orca/orca_test.go +134 -160
  54. package/internal/runner/orca/orcacli/orcacli.go +215 -0
  55. package/internal/runner/orca/orcacli/orcacli_test.go +154 -0
  56. package/internal/runner/orca/orcacli/testdata/repo-list.json +18 -0
  57. package/internal/runner/orca/orcacli/testdata/strict-orca.sh +30 -0
  58. package/internal/runner/orca/orcacli/testdata/terminal-close.json +12 -0
  59. package/internal/runner/orca/orcacli/testdata/terminal-create.json +18 -0
  60. package/internal/runner/orca/orcacli/testdata/terminal-list.json +51 -0
  61. package/internal/runner/orca/orcacli/testdata/terminal-send.json +1 -0
  62. package/internal/runner/orca/orcacli/testdata/terminal-show.json +1 -0
  63. package/internal/runner/orca/orcacli/testdata/worktree-create.json +22 -0
  64. package/internal/runner/orca/orcacli/testdata/worktree-list.json +31 -0
  65. package/internal/runner/orca/orcacli/testdata/worktree-remove.json +6 -0
  66. package/internal/runner/runner.go +47 -64
  67. package/internal/server/api_test.go +300 -0
  68. package/internal/server/client.go +192 -74
  69. package/internal/server/fixture_test.go +248 -0
  70. package/internal/server/server.go +425 -248
  71. package/internal/server/shutdown_test.go +116 -0
  72. package/internal/task/contract_test.go +223 -0
  73. package/internal/task/factory.go +103 -0
  74. package/internal/task/jira/acli/acli.go +306 -0
  75. package/internal/task/jira/acli/acli_test.go +208 -0
  76. package/internal/task/jira/acli/testdata/acli_comments.json +55 -0
  77. package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +1 -0
  78. package/internal/task/jira/acli/testdata/search_invalid_status.txt +1 -0
  79. package/internal/task/jira/acli/testdata/search_success.json +1 -0
  80. package/internal/task/jira/filters_test.go +234 -0
  81. package/internal/task/jira/helpers_test.go +60 -0
  82. package/internal/task/jira/jira.go +507 -0
  83. package/internal/task/jira/normalize.go +101 -0
  84. package/internal/task/jira/testdata/acli_search.json +120 -0
  85. package/internal/task/jira/transition_defaults_test.go +156 -0
  86. package/internal/task/jira/validation_test.go +94 -0
  87. package/internal/task/task.go +84 -0
  88. package/internal/workflow/report.go +85 -0
  89. package/internal/workflow/report_test.go +259 -0
  90. package/internal/workflow/service.go +142 -0
  91. package/internal/workflow/store.go +136 -0
  92. package/internal/workflow/store_test.go +282 -0
  93. package/internal/workflow/workflow.go +342 -0
  94. package/internal/workflow/workflow_test.go +410 -0
  95. package/package.json +1 -1
  96. package/internal/acli/acli.go +0 -229
  97. package/internal/config/demo_test.go +0 -17
  98. package/internal/config/schema.go +0 -193
  99. package/internal/config/schema_test.go +0 -162
  100. package/internal/daemon/daemon.go +0 -218
  101. package/internal/daemon/daemon_test.go +0 -204
  102. package/internal/discovery/discovery.go +0 -122
  103. package/internal/discovery/discovery_test.go +0 -62
  104. package/internal/opencode/opencode.go +0 -26
  105. package/internal/orcacli/orcacli.go +0 -264
  106. package/internal/runner/orca/README.md +0 -64
  107. package/internal/runner/runner_test.go +0 -64
  108. package/internal/server/server_test.go +0 -195
  109. package/internal/tasks/jira/README.md +0 -69
  110. package/internal/tasks/jira/component_test.go +0 -16
  111. package/internal/tasks/jira/decode.go +0 -24
  112. package/internal/tasks/jira/jira.go +0 -231
  113. package/internal/tasks/jira/jira_test.go +0 -259
  114. package/internal/tasks/jira/jql_test.go +0 -16
  115. package/internal/tasks/tasks.go +0 -90
  116. 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("")