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,116 @@
|
|
|
1
|
+
package server_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"net/http"
|
|
7
|
+
"testing"
|
|
8
|
+
"time"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
// 3.37: graceful shutdown per specs/workflow-repo-management "Graceful
|
|
12
|
+
// shutdown is bounded". Uses the handler fixture on a real Unix socket so a
|
|
13
|
+
// restart on the same dir resumes the same state.
|
|
14
|
+
|
|
15
|
+
func TestShutdownStopsAcceptingWithinBound(t *testing.T) {
|
|
16
|
+
dir := t.TempDir()
|
|
17
|
+
c, shutdown := startHandlerOnSocket(t, dir, &fakeServices{})
|
|
18
|
+
|
|
19
|
+
if _, err := c.Get("http://unix/workflows"); err != nil {
|
|
20
|
+
t.Fatalf("pre-shutdown request failed: %v", err)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
start := time.Now()
|
|
24
|
+
shutdown()
|
|
25
|
+
if elapsed := time.Since(start); elapsed > 35*time.Second {
|
|
26
|
+
t.Fatalf("shutdown took %v, exceeding the bounded 30s wait", elapsed)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if _, err := c.Get("http://unix/workflows"); err == nil {
|
|
30
|
+
t.Fatal("server still accepting requests after shutdown")
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
func TestShutdownWaitsForRunningCall(t *testing.T) {
|
|
35
|
+
dir := t.TempDir()
|
|
36
|
+
release := make(chan struct{})
|
|
37
|
+
c, shutdown := startHandlerOnSocket(t, dir, &fakeServices{slowReport: release})
|
|
38
|
+
|
|
39
|
+
done := make(chan error, 1)
|
|
40
|
+
go func() {
|
|
41
|
+
resp, err := c.Post("http://unix/reports", "application/json", slowReportBody(t))
|
|
42
|
+
if err == nil {
|
|
43
|
+
resp.Body.Close()
|
|
44
|
+
}
|
|
45
|
+
done <- err
|
|
46
|
+
}()
|
|
47
|
+
time.Sleep(100 * time.Millisecond) // let the slow request start
|
|
48
|
+
|
|
49
|
+
stopped := make(chan struct{})
|
|
50
|
+
go func() { shutdown(); close(stopped) }()
|
|
51
|
+
|
|
52
|
+
// While the report is in flight, shutdown must be waiting.
|
|
53
|
+
select {
|
|
54
|
+
case <-stopped:
|
|
55
|
+
t.Fatal("shutdown returned while a call was still running")
|
|
56
|
+
case <-time.After(300 * time.Millisecond):
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Let the running call return; shutdown then completes within the bound.
|
|
60
|
+
close(release)
|
|
61
|
+
start := time.Now()
|
|
62
|
+
select {
|
|
63
|
+
case <-stopped:
|
|
64
|
+
case <-time.After(35 * time.Second):
|
|
65
|
+
t.Fatal("shutdown did not complete after the running call returned")
|
|
66
|
+
}
|
|
67
|
+
if err := <-done; err != nil {
|
|
68
|
+
t.Fatalf("running call interrupted by shutdown: %v", err)
|
|
69
|
+
}
|
|
70
|
+
if elapsed := time.Since(start); elapsed > 35*time.Second {
|
|
71
|
+
t.Fatalf("shutdown exceeded the bounded wait: %v", elapsed)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
func TestRestartOnSameStateResumes(t *testing.T) {
|
|
76
|
+
// Same backing services across shutdown+restart resume the same state.
|
|
77
|
+
// The fake services instance is shared so its store survives the restart.
|
|
78
|
+
dir := t.TempDir()
|
|
79
|
+
shared := &fakeServices{}
|
|
80
|
+
c1, shutdown1 := startHandlerOnSocket(t, dir, shared)
|
|
81
|
+
|
|
82
|
+
// Submit a workflow so there is state. POST /workflows body IS raw YAML.
|
|
83
|
+
yamlBody := "name: basicFlow\nrepos: [payments]\nnodes:\n start: {onSuccess: [{target: end}]}\n end: {}\n"
|
|
84
|
+
resp, err := c1.Post("http://unix/workflows", "application/yaml", bytes.NewReader([]byte(yamlBody)))
|
|
85
|
+
if err != nil {
|
|
86
|
+
t.Fatal(err)
|
|
87
|
+
}
|
|
88
|
+
resp.Body.Close()
|
|
89
|
+
shutdown1()
|
|
90
|
+
|
|
91
|
+
// Restart on the same dir with the SAME services; the workflow persists.
|
|
92
|
+
// Durable unfinished-run resume across restart is the engine's
|
|
93
|
+
// responsibility and is covered by the goworkflows same-db restart tests
|
|
94
|
+
// (TestVisitIDStableAcrossNormalRestart / crash tests); the server layer
|
|
95
|
+
// resumes serving the same backing state.
|
|
96
|
+
c2, shutdown2 := startHandlerOnSocket(t, dir, shared)
|
|
97
|
+
defer shutdown2()
|
|
98
|
+
code, env := do(t, c2, http.MethodGet, "http://unix/workflows", nil)
|
|
99
|
+
if code != http.StatusOK || !env.OK {
|
|
100
|
+
t.Fatalf("restart list: code=%d env=%+v", code, env)
|
|
101
|
+
}
|
|
102
|
+
var wfs []map[string]any
|
|
103
|
+
if err := json.Unmarshal(env.Data, &wfs); err != nil || len(wfs) != 1 {
|
|
104
|
+
t.Fatalf("state not resumed after restart: data=%s err=%v", env.Data, err)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
func slowReportBody(t *testing.T) *bytes.Reader {
|
|
109
|
+
t.Helper()
|
|
110
|
+
return bytes.NewReader([]byte(`{"runId":"r","node":"coding","reportId":"s:m","report":{"status":"success","nextStep":"end","summary":{"completed":"x","commits":"abc123","notCompleted":"None","issuesDiscovered":"None","verification":"x","notes":"None"},"feedback":{"reasonForNextStep":"None","requiredActions":"None","relevantContext":"None","expectedResult":"None"}}}`))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// The server.sock ownership/mode is set by the serve startup path (5.5), which
|
|
114
|
+
// binds and chmods the socket; server.New returns only an http.Handler and does
|
|
115
|
+
// not create sockets. The 0600 assertion lives in cmd/relay-flow/commands_test.go
|
|
116
|
+
// (TestServerSocketIsOwnerOnly) which exercises the serve fixture. See 3.29.
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
package task_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"testing"
|
|
6
|
+
|
|
7
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
8
|
+
"github.com/rajpopat27/relay-flow/internal/task"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
// 3.6: task-system contract tests against a fake adapter, per
|
|
12
|
+
// specs/integration-contracts. The fake implements task.System and the
|
|
13
|
+
// tests assert the contract shape and primitive separation.
|
|
14
|
+
|
|
15
|
+
// fakeSystem is a minimal in-memory task.System used by contract tests. It
|
|
16
|
+
// holds a mixed backing store (active parents, mailbox subtasks, completed
|
|
17
|
+
// parents) and Poll returns only active parents, per the contract.
|
|
18
|
+
type fakeSystem struct {
|
|
19
|
+
all []storedTicket // mixed backing store
|
|
20
|
+
mailboxes map[string]task.Mailbox
|
|
21
|
+
completed []string
|
|
22
|
+
comments []commentCall
|
|
23
|
+
applied []applyCall
|
|
24
|
+
resets []string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type storedTicket struct {
|
|
28
|
+
ticket task.Ticket
|
|
29
|
+
isMailbox bool
|
|
30
|
+
active bool
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type commentCall struct {
|
|
34
|
+
Target task.Target
|
|
35
|
+
Body string
|
|
36
|
+
Marker string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type applyCall struct {
|
|
40
|
+
Target task.Target
|
|
41
|
+
TaskConfig config.RawValues
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
func newFakeSystem() *fakeSystem {
|
|
45
|
+
return &fakeSystem{mailboxes: map[string]task.Mailbox{}}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
func (f *fakeSystem) Poll(context.Context) ([]task.Ticket, error) {
|
|
49
|
+
var out []task.Ticket
|
|
50
|
+
for _, st := range f.all {
|
|
51
|
+
if st.active && !st.isMailbox {
|
|
52
|
+
out = append(out, st.ticket)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return out, nil
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
func (f *fakeSystem) CompileFilter(config.RawValues) (func(task.Ticket) bool, error) {
|
|
59
|
+
return func(task.Ticket) bool { return true }, nil
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
func (f *fakeSystem) Claim(context.Context, task.TicketRef, string) error { return nil }
|
|
63
|
+
|
|
64
|
+
func (f *fakeSystem) ValidateConfig(context.Context, config.RawValues, map[string]config.RawValues) error {
|
|
65
|
+
return nil
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
func (f *fakeSystem) EnsureMailboxes(_ context.Context, _ task.TicketRef, _ string, specs []task.MailboxSpec) (map[string]task.Mailbox, error) {
|
|
69
|
+
for _, s := range specs {
|
|
70
|
+
if _, ok := f.mailboxes[s.Node]; !ok {
|
|
71
|
+
f.mailboxes[s.Node] = task.Mailbox{ID: "mb-" + s.Node, Key: "PAY-" + s.Node, Node: s.Node}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
out := map[string]task.Mailbox{}
|
|
75
|
+
for _, s := range specs {
|
|
76
|
+
out[s.Node] = f.mailboxes[s.Node]
|
|
77
|
+
}
|
|
78
|
+
return out, nil
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
func (f *fakeSystem) ApplyTaskConfig(_ context.Context, target task.Target, cfg config.RawValues) error {
|
|
82
|
+
f.applied = append(f.applied, applyCall{Target: target, TaskConfig: cfg})
|
|
83
|
+
return nil
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
func (f *fakeSystem) CompleteMailbox(_ context.Context, mb task.Mailbox) error {
|
|
87
|
+
f.completed = append(f.completed, mb.Key)
|
|
88
|
+
return nil
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
func (f *fakeSystem) HasComment(_ context.Context, _ task.Target, marker string) (bool, error) {
|
|
92
|
+
for _, c := range f.comments {
|
|
93
|
+
if c.Marker == marker {
|
|
94
|
+
return true, nil
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return false, nil
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
func (f *fakeSystem) Comment(_ context.Context, target task.Target, body, marker string) error {
|
|
101
|
+
f.comments = append(f.comments, commentCall{Target: target, Body: body, Marker: marker})
|
|
102
|
+
return nil
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
func (f *fakeSystem) ResetForRecovery(_ context.Context, parent task.TicketRef, _ []task.Mailbox, _ config.RawValues) error {
|
|
106
|
+
f.resets = append(f.resets, parent.Key)
|
|
107
|
+
return nil
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
func TestPollReturnsActiveParentsOnly(t *testing.T) {
|
|
111
|
+
f := newFakeSystem()
|
|
112
|
+
// Mixed backing store: an active parent, a mailbox subtask that also
|
|
113
|
+
// carries the wf: label, and a completed (inactive) parent.
|
|
114
|
+
f.all = []storedTicket{
|
|
115
|
+
{ticket: task.Ticket{ID: "1", Key: "PAY-101", Title: "parent", WorkflowClaims: []string{"wf:basicFlow"}}, active: true},
|
|
116
|
+
{ticket: task.Ticket{ID: "2", Key: "PAY-102", Title: "PAY-101:coding", WorkflowClaims: []string{"wf:basicFlow"}}, isMailbox: true, active: true},
|
|
117
|
+
{ticket: task.Ticket{ID: "3", Key: "PAY-103", Title: "done parent"}, active: false},
|
|
118
|
+
}
|
|
119
|
+
f.mailboxes["coding"] = task.Mailbox{ID: "2", Key: "PAY-102", Node: "coding"}
|
|
120
|
+
|
|
121
|
+
got, err := f.Poll(context.Background())
|
|
122
|
+
if err != nil {
|
|
123
|
+
t.Fatalf("Poll failed: %v", err)
|
|
124
|
+
}
|
|
125
|
+
if len(got) != 1 || got[0].Key != "PAY-101" {
|
|
126
|
+
t.Fatalf("Poll = %+v, want only the active parent PAY-101", got)
|
|
127
|
+
}
|
|
128
|
+
for _, ticket := range got {
|
|
129
|
+
if ticket.Key == "PAY-102" {
|
|
130
|
+
t.Fatal("Poll returned a mailbox subtask as a run candidate")
|
|
131
|
+
}
|
|
132
|
+
if ticket.Key == "PAY-103" {
|
|
133
|
+
t.Fatal("Poll returned an inactive/completed parent")
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
func TestEnsureMailboxesFindsExistingCreatesOnlyMissing(t *testing.T) {
|
|
139
|
+
f := newFakeSystem()
|
|
140
|
+
parent := task.TicketRef{ID: "1", Key: "PAY-101", Title: "parent"}
|
|
141
|
+
specs := []task.MailboxSpec{
|
|
142
|
+
{Node: "exploration", Title: "PAY-101:exploration", Description: "explore"},
|
|
143
|
+
{Node: "coding", Title: "PAY-101:coding", Description: "code"},
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
first, err := f.EnsureMailboxes(context.Background(), parent, "basicFlow", specs)
|
|
147
|
+
if err != nil {
|
|
148
|
+
t.Fatalf("EnsureMailboxes failed: %v", err)
|
|
149
|
+
}
|
|
150
|
+
if len(first) != 2 {
|
|
151
|
+
t.Fatalf("EnsureMailboxes returned %d mailboxes, want 2", len(first))
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Second call: finds existing, creates none.
|
|
155
|
+
second, err := f.EnsureMailboxes(context.Background(), parent, "basicFlow", specs)
|
|
156
|
+
if err != nil {
|
|
157
|
+
t.Fatalf("EnsureMailboxes (repeat) failed: %v", err)
|
|
158
|
+
}
|
|
159
|
+
for node, mb := range first {
|
|
160
|
+
if second[node] != mb {
|
|
161
|
+
t.Fatalf("node %s: mailbox %v changed to %v on repeat; must be reused", node, mb, second[node])
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Add a node: only the missing one is created.
|
|
166
|
+
specs = append(specs, task.MailboxSpec{Node: "review", Title: "PAY-101:review", Description: "review"})
|
|
167
|
+
third, err := f.EnsureMailboxes(context.Background(), parent, "basicFlow", specs)
|
|
168
|
+
if err != nil {
|
|
169
|
+
t.Fatalf("EnsureMailboxes (extended) failed: %v", err)
|
|
170
|
+
}
|
|
171
|
+
if len(third) != 3 {
|
|
172
|
+
t.Fatalf("EnsureMailboxes returned %d, want complete map of 3", len(third))
|
|
173
|
+
}
|
|
174
|
+
if third["exploration"] != first["exploration"] || third["coding"] != first["coding"] {
|
|
175
|
+
t.Fatal("existing mailboxes were recreated instead of found")
|
|
176
|
+
}
|
|
177
|
+
if third["review"].Node != "review" {
|
|
178
|
+
t.Fatalf("missing mailbox not created: %+v", third["review"])
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
func TestCompleteMailboxIsNarrow(t *testing.T) {
|
|
183
|
+
f := newFakeSystem()
|
|
184
|
+
mb := task.Mailbox{ID: "2", Key: "PAY-102", Node: "coding"}
|
|
185
|
+
if err := f.CompleteMailbox(context.Background(), mb); err != nil {
|
|
186
|
+
t.Fatalf("CompleteMailbox failed: %v", err)
|
|
187
|
+
}
|
|
188
|
+
if len(f.completed) != 1 || f.completed[0] != "PAY-102" {
|
|
189
|
+
t.Fatalf("completed = %v", f.completed)
|
|
190
|
+
}
|
|
191
|
+
// CompleteMailbox performs no comment/routing/runner work.
|
|
192
|
+
if len(f.comments) != 0 {
|
|
193
|
+
t.Fatalf("CompleteMailbox wrote comments: %v", f.comments)
|
|
194
|
+
}
|
|
195
|
+
if len(f.applied) != 0 {
|
|
196
|
+
t.Fatalf("CompleteMailbox applied task config: %v", f.applied)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
func TestSeparatePrimitives(t *testing.T) {
|
|
201
|
+
// Compile-time: the System interface exposes each primitive separately.
|
|
202
|
+
var sys task.System = newFakeSystem()
|
|
203
|
+
ctx := context.Background()
|
|
204
|
+
parent := task.TicketRef{ID: "1", Key: "PAY-101"}
|
|
205
|
+
mb := task.Mailbox{ID: "2", Key: "PAY-102", Node: "coding"}
|
|
206
|
+
target := task.Target{Parent: parent, Mailbox: &mb}
|
|
207
|
+
|
|
208
|
+
if err := sys.ApplyTaskConfig(ctx, target, config.RawValues{}); err != nil {
|
|
209
|
+
t.Fatal(err)
|
|
210
|
+
}
|
|
211
|
+
if err := sys.CompleteMailbox(ctx, mb); err != nil {
|
|
212
|
+
t.Fatal(err)
|
|
213
|
+
}
|
|
214
|
+
if _, err := sys.HasComment(ctx, target, "m"); err != nil {
|
|
215
|
+
t.Fatal(err)
|
|
216
|
+
}
|
|
217
|
+
if err := sys.Comment(ctx, target, "body", "m"); err != nil {
|
|
218
|
+
t.Fatal(err)
|
|
219
|
+
}
|
|
220
|
+
if err := sys.ResetForRecovery(ctx, parent, []task.Mailbox{mb}, config.RawValues{}); err != nil {
|
|
221
|
+
t.Fatal(err)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
package task
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"fmt"
|
|
6
|
+
"sort"
|
|
7
|
+
"strings"
|
|
8
|
+
"sync"
|
|
9
|
+
|
|
10
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
// RepoSpec carries the registration values used to construct a repo-bound
|
|
14
|
+
// task System.
|
|
15
|
+
type RepoSpec struct {
|
|
16
|
+
Name string
|
|
17
|
+
Path string
|
|
18
|
+
RootConfig config.RawValues
|
|
19
|
+
RepoConfig config.RawValues
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Factory constructs a task System. RequiredRepoKeys returns the explicit
|
|
23
|
+
// repo YAML keys needed at registration. TaskScopeKey derives an opaque
|
|
24
|
+
// canonical physical task scope (such as Jira site/project/component) used
|
|
25
|
+
// to reject duplicate scope registration.
|
|
26
|
+
type Factory struct {
|
|
27
|
+
RequiredRepoKeys func() []string
|
|
28
|
+
TaskScopeKey func(rootConfig, repoConfig config.RawValues) (string, error)
|
|
29
|
+
New func(context.Context, RepoSpec) (System, error)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
var (
|
|
33
|
+
registryMu sync.RWMutex
|
|
34
|
+
registry = map[string]Factory{}
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
// Register adds a task factory by name. Duplicate registration panics.
|
|
38
|
+
func Register(name string, factory Factory) {
|
|
39
|
+
registryMu.Lock()
|
|
40
|
+
defer registryMu.Unlock()
|
|
41
|
+
if _, exists := registry[name]; exists {
|
|
42
|
+
panic(fmt.Sprintf("task: duplicate registration of %q", name))
|
|
43
|
+
}
|
|
44
|
+
registry[name] = factory
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
func lookup(name string) (Factory, error) {
|
|
48
|
+
registryMu.RLock()
|
|
49
|
+
defer registryMu.RUnlock()
|
|
50
|
+
f, ok := registry[name]
|
|
51
|
+
if !ok {
|
|
52
|
+
return Factory{}, fmt.Errorf("task: unknown plugin %q (registered: %s)", name, strings.Join(Names(), ", "))
|
|
53
|
+
}
|
|
54
|
+
return f, nil
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// New constructs the repo-bound task System for the named plugin.
|
|
58
|
+
func New(ctx context.Context, name string, spec RepoSpec) (System, error) {
|
|
59
|
+
f, err := lookup(name)
|
|
60
|
+
if err != nil {
|
|
61
|
+
return nil, err
|
|
62
|
+
}
|
|
63
|
+
return f.New(ctx, spec)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// RequiredRepoKeys returns the repo YAML keys the named plugin requires at
|
|
67
|
+
// registration.
|
|
68
|
+
func RequiredRepoKeys(name string) ([]string, error) {
|
|
69
|
+
f, err := lookup(name)
|
|
70
|
+
if err != nil {
|
|
71
|
+
return nil, err
|
|
72
|
+
}
|
|
73
|
+
return f.RequiredRepoKeys(), nil
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// TaskScopeKey derives the canonical task scope for the named plugin.
|
|
77
|
+
func TaskScopeKey(name string, rootConfig, repoConfig config.RawValues) (string, error) {
|
|
78
|
+
f, err := lookup(name)
|
|
79
|
+
if err != nil {
|
|
80
|
+
return "", err
|
|
81
|
+
}
|
|
82
|
+
return f.TaskScopeKey(rootConfig, repoConfig)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ValidateName returns an error listing registered names when name is not
|
|
86
|
+
// a registered plugin. Used by `relay-flow init` to reject unknown plugin
|
|
87
|
+
// selections without constructing a System.
|
|
88
|
+
func ValidateName(name string) error {
|
|
89
|
+
_, err := lookup(name)
|
|
90
|
+
return err
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Names returns the registered plugin names sorted.
|
|
94
|
+
func Names() []string {
|
|
95
|
+
registryMu.RLock()
|
|
96
|
+
defer registryMu.RUnlock()
|
|
97
|
+
out := make([]string, 0, len(registry))
|
|
98
|
+
for name := range registry {
|
|
99
|
+
out = append(out, name)
|
|
100
|
+
}
|
|
101
|
+
sort.Strings(out)
|
|
102
|
+
return out
|
|
103
|
+
}
|