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,126 @@
1
+ package harness_test
2
+
3
+ // This file intentionally lives in one package but exercises all three
4
+ // plugin registries (task, runner, harness); they share one behavior.
5
+
6
+ import (
7
+ "context"
8
+ "strings"
9
+ "testing"
10
+
11
+ "github.com/rajpopat27/relay-flow/internal/config"
12
+ "github.com/rajpopat27/relay-flow/internal/harness"
13
+ "github.com/rajpopat27/relay-flow/internal/runner"
14
+ "github.com/rajpopat27/relay-flow/internal/task"
15
+ )
16
+
17
+ // 3.9: plugin selection per specs/integration-contracts "Plugin types are
18
+ // selected at machine scope": one plugin of each kind selected at root,
19
+ // unknown names error listing registered names, duplicate registration
20
+ // panics.
21
+
22
+ func TestHarnessRegistryUnknownNameListsRegistered(t *testing.T) {
23
+ harness.Register("fakeharness-unknown", func(config.RawValues) (harness.Harness, error) {
24
+ return newFakeHarness(), nil
25
+ })
26
+ _, err := harness.New("does-not-exist", config.RawValues{})
27
+ if err == nil {
28
+ t.Fatal("unknown harness name accepted")
29
+ }
30
+ if !strings.Contains(err.Error(), "fakeharness-unknown") {
31
+ t.Fatalf("error %q does not list registered names", err)
32
+ }
33
+ }
34
+
35
+ func TestRunnerRegistryUnknownNameListsRegistered(t *testing.T) {
36
+ runner.Register("fakerunner-unknown", func(config.RawValues) (runner.Runner, error) {
37
+ return nil, nil
38
+ })
39
+ _, err := runner.New("does-not-exist", config.RawValues{})
40
+ if err == nil {
41
+ t.Fatal("unknown runner name accepted")
42
+ }
43
+ if !strings.Contains(err.Error(), "fakerunner-unknown") {
44
+ t.Fatalf("error %q does not list registered names", err)
45
+ }
46
+ }
47
+
48
+ func TestTaskRegistryUnknownNameListsRegistered(t *testing.T) {
49
+ task.Register("faketask-unknown", task.Factory{
50
+ RequiredRepoKeys: func() []string { return nil },
51
+ TaskScopeKey: func(_, _ config.RawValues) (string, error) {
52
+ return "scope", nil
53
+ },
54
+ New: func(context.Context, task.RepoSpec) (task.System, error) {
55
+ return nil, nil
56
+ },
57
+ })
58
+ _, err := task.New(context.Background(), "does-not-exist", task.RepoSpec{})
59
+ if err == nil {
60
+ t.Fatal("unknown task name accepted")
61
+ }
62
+ if !strings.Contains(err.Error(), "faketask-unknown") {
63
+ t.Fatalf("error %q does not list registered names", err)
64
+ }
65
+ }
66
+
67
+ func TestDuplicateRegistrationPanics(t *testing.T) {
68
+ assertPanic := func(name string, fn func()) {
69
+ t.Helper()
70
+ defer func() {
71
+ if r := recover(); r == nil {
72
+ t.Fatalf("%s duplicate registration did not panic", name)
73
+ }
74
+ }()
75
+ fn()
76
+ }
77
+
78
+ harness.Register("dup-harness-x", func(config.RawValues) (harness.Harness, error) { return nil, nil })
79
+ assertPanic("harness", func() {
80
+ harness.Register("dup-harness-x", func(config.RawValues) (harness.Harness, error) { return nil, nil })
81
+ })
82
+
83
+ runner.Register("dup-runner-x", func(config.RawValues) (runner.Runner, error) { return nil, nil })
84
+ assertPanic("runner", func() {
85
+ runner.Register("dup-runner-x", func(config.RawValues) (runner.Runner, error) { return nil, nil })
86
+ })
87
+
88
+ factory := task.Factory{
89
+ RequiredRepoKeys: func() []string { return nil },
90
+ TaskScopeKey: func(_, _ config.RawValues) (string, error) { return "s", nil },
91
+ New: func(context.Context, task.RepoSpec) (task.System, error) { return nil, nil },
92
+ }
93
+ task.Register("dup-task-x", factory)
94
+ assertPanic("task", func() { task.Register("dup-task-x", factory) })
95
+ }
96
+
97
+ func TestNamesListsRegistered(t *testing.T) {
98
+ // Self-contained: register fixtures within this test rather than relying
99
+ // on earlier tests in the binary.
100
+ harness.Register("names-harness-x", func(config.RawValues) (harness.Harness, error) { return nil, nil })
101
+ runner.Register("names-runner-x", func(config.RawValues) (runner.Runner, error) { return nil, nil })
102
+ task.Register("names-task-x", task.Factory{
103
+ RequiredRepoKeys: func() []string { return nil },
104
+ TaskScopeKey: func(_, _ config.RawValues) (string, error) { return "s", nil },
105
+ New: func(context.Context, task.RepoSpec) (task.System, error) { return nil, nil },
106
+ })
107
+
108
+ if !contains(harness.Names(), "names-harness-x") {
109
+ t.Fatalf("harness.Names() = %v", harness.Names())
110
+ }
111
+ if !contains(runner.Names(), "names-runner-x") {
112
+ t.Fatalf("runner.Names() = %v", runner.Names())
113
+ }
114
+ if !contains(task.Names(), "names-task-x") {
115
+ t.Fatalf("task.Names() = %v", task.Names())
116
+ }
117
+ }
118
+
119
+ func contains(xs []string, want string) bool {
120
+ for _, x := range xs {
121
+ if x == want {
122
+ return true
123
+ }
124
+ }
125
+ return false
126
+ }
@@ -0,0 +1,37 @@
1
+ // Package identity defines the opaque run and node-visit identifiers used
2
+ // across relay-flow. It imports no other relay-flow package.
3
+ package identity
4
+
5
+ import (
6
+ "crypto/rand"
7
+ "encoding/hex"
8
+ "net/url"
9
+ "strings"
10
+ )
11
+
12
+ // RunID identifies one durable run. Opaque to consumers.
13
+ type RunID string
14
+
15
+ // NodeVisitID identifies one entry into a workflow node. Opaque to consumers.
16
+ type NodeVisitID string
17
+
18
+ // NewRunID returns the deterministic run ID for a (repo, workflow, ticket)
19
+ // triple. Each component is path-escaped so the joined ID is delimiter-safe.
20
+ func NewRunID(repo, workflow, ticket string) RunID {
21
+ return RunID(strings.Join([]string{
22
+ url.PathEscape(repo),
23
+ url.PathEscape(workflow),
24
+ url.PathEscape(ticket),
25
+ }, "/"))
26
+ }
27
+
28
+ // NewNodeVisitID returns a fresh random node-visit ID. Generation happens
29
+ // once per node entry as a durable replay-safe side effect; this function
30
+ // only produces the random value.
31
+ func NewNodeVisitID() NodeVisitID {
32
+ var b [16]byte
33
+ if _, err := rand.Read(b[:]); err != nil {
34
+ panic(err)
35
+ }
36
+ return NodeVisitID(hex.EncodeToString(b[:]))
37
+ }
@@ -0,0 +1,56 @@
1
+ // Package logging configures the process-wide slog logger for serve.
2
+ // Task 9.1: --debug serve flag OR RELAY_FLOW_LOG_LEVEL env (flag wins);
3
+ // default info. Output goes to server.log AND stderr, stdlib slog only.
4
+ package logging
5
+
6
+ import (
7
+ "fmt"
8
+ "io"
9
+ "log/slog"
10
+ "os"
11
+ "strings"
12
+ )
13
+
14
+ // Options control Setup. Debug is the --debug serve flag; Env is the value
15
+ // of RELAY_FLOW_LOG_LEVEL (read once by the caller for testability).
16
+ // Flag wins over env.
17
+ type Options struct {
18
+ Debug bool
19
+ Env string
20
+ }
21
+
22
+ // Setup installs the process-wide slog logger writing to logPath AND
23
+ // stderr. Effective level: --debug flag → debug; else RELAY_FLOW_LOG_LEVEL
24
+ // (debug|info|warn|error, case-insensitive); else info. Unknown env values
25
+ // are ignored (info). The returned closer flushes/closes the log file; it
26
+ // is safe to call more than once.
27
+ func Setup(logPath string, o Options) (io.Closer, error) {
28
+ f, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
29
+ if err != nil {
30
+ return nil, fmt.Errorf("open server log %s: %w", logPath, err)
31
+ }
32
+ if err := f.Chmod(0o600); err != nil {
33
+ _ = f.Close()
34
+ return nil, fmt.Errorf("chmod server log %s: %w", logPath, err)
35
+ }
36
+ level := levelFor(o)
37
+ h := slog.NewTextHandler(io.MultiWriter(f, os.Stderr), &slog.HandlerOptions{Level: level})
38
+ slog.SetDefault(slog.New(h))
39
+ return f, nil
40
+ }
41
+
42
+ func levelFor(o Options) slog.Level {
43
+ if o.Debug {
44
+ return slog.LevelDebug
45
+ }
46
+ switch strings.ToLower(strings.TrimSpace(o.Env)) {
47
+ case "debug":
48
+ return slog.LevelDebug
49
+ case "warn":
50
+ return slog.LevelWarn
51
+ case "error":
52
+ return slog.LevelError
53
+ default:
54
+ return slog.LevelInfo
55
+ }
56
+ }
@@ -0,0 +1,116 @@
1
+ package logging
2
+
3
+ import (
4
+ "bytes"
5
+ "io"
6
+ "log/slog"
7
+ "os"
8
+ "path/filepath"
9
+ "strings"
10
+ "testing"
11
+ )
12
+
13
+ // 9.1: level resolution — --debug flag wins over RELAY_FLOW_LOG_LEVEL;
14
+ // default is info.
15
+ func TestLevelFor(t *testing.T) {
16
+ cases := []struct {
17
+ name string
18
+ opts Options
19
+ want slog.Level
20
+ }{
21
+ {"default info", Options{}, slog.LevelInfo},
22
+ {"flag debug", Options{Debug: true}, slog.LevelDebug},
23
+ {"flag wins over env info", Options{Debug: true, Env: "info"}, slog.LevelDebug},
24
+ {"flag wins over env error", Options{Debug: true, Env: "error"}, slog.LevelDebug},
25
+ {"env debug", Options{Env: "debug"}, slog.LevelDebug},
26
+ {"env warn", Options{Env: "warn"}, slog.LevelWarn},
27
+ {"env error", Options{Env: "ERROR"}, slog.LevelError},
28
+ {"env unknown ignored", Options{Env: "bogus"}, slog.LevelInfo},
29
+ {"env empty", Options{Env: ""}, slog.LevelInfo},
30
+ }
31
+ for _, c := range cases {
32
+ t.Run(c.name, func(t *testing.T) {
33
+ if got := levelFor(c.opts); got != c.want {
34
+ t.Fatalf("levelFor(%+v) = %v, want %v", c.opts, got, c.want)
35
+ }
36
+ })
37
+ }
38
+ }
39
+
40
+ // 9.1: Setup writes to BOTH server.log and stderr, creates the log file
41
+ // 0600, and honors the configured level.
42
+ func TestSetupWritesFileAndStderr(t *testing.T) {
43
+ dir := t.TempDir()
44
+ logPath := filepath.Join(dir, "server.log")
45
+
46
+ // Capture stderr during Setup + one log call.
47
+ oldStderr := os.Stderr
48
+ r, w, err := os.Pipe()
49
+ if err != nil {
50
+ t.Fatal(err)
51
+ }
52
+ os.Stderr = w
53
+ defer func() { os.Stderr = oldStderr }()
54
+
55
+ closer, err := Setup(logPath, Options{Debug: true})
56
+ if err != nil {
57
+ t.Fatalf("Setup: %v", err)
58
+ }
59
+ defer closer.Close()
60
+
61
+ slog.Debug("hello-debug", "ticket", "PAY-1")
62
+ slog.Info("hello-info", "ticket", "PAY-2")
63
+
64
+ _ = w.Close()
65
+ os.Stderr = oldStderr
66
+ var stderrBuf bytes.Buffer
67
+ _, _ = io.Copy(&stderrBuf, r)
68
+ stderrOut := stderrBuf.String()
69
+
70
+ if !strings.Contains(stderrOut, "hello-debug") || !strings.Contains(stderrOut, "hello-info") {
71
+ t.Fatalf("stderr missing expected lines; got:\n%s", stderrOut)
72
+ }
73
+
74
+ fi, err := os.Stat(logPath)
75
+ if err != nil {
76
+ t.Fatalf("server.log not created: %v", err)
77
+ }
78
+ if fi.Mode().Perm() != 0o600 {
79
+ t.Fatalf("server.log mode = %o, want 0600", fi.Mode().Perm())
80
+ }
81
+ body, err := os.ReadFile(logPath)
82
+ if err != nil {
83
+ t.Fatal(err)
84
+ }
85
+ text := string(body)
86
+ if !strings.Contains(text, "hello-debug") || !strings.Contains(text, "hello-info") {
87
+ t.Fatalf("server.log missing expected lines; got:\n%s", text)
88
+ }
89
+ if !strings.Contains(text, "ticket=PAY-1") || !strings.Contains(text, "ticket=PAY-2") {
90
+ t.Fatalf("server.log missing structured ticket attr; got:\n%s", text)
91
+ }
92
+ }
93
+
94
+ // 9.1: default (no flag, no env) is info — debug lines are suppressed.
95
+ func TestSetupDefaultInfoSuppressesDebug(t *testing.T) {
96
+ dir := t.TempDir()
97
+ logPath := filepath.Join(dir, "server.log")
98
+ closer, err := Setup(logPath, Options{})
99
+ if err != nil {
100
+ t.Fatalf("Setup: %v", err)
101
+ }
102
+ defer closer.Close()
103
+ slog.Debug("suppressed-debug")
104
+ slog.Info("kept-info")
105
+ body, err := os.ReadFile(logPath)
106
+ if err != nil {
107
+ t.Fatal(err)
108
+ }
109
+ text := string(body)
110
+ if strings.Contains(text, "suppressed-debug") {
111
+ t.Fatalf("debug line should be suppressed at info level; got:\n%s", text)
112
+ }
113
+ if !strings.Contains(text, "kept-info") {
114
+ t.Fatalf("info line missing; got:\n%s", text)
115
+ }
116
+ }
@@ -0,0 +1,67 @@
1
+ // Package paths computes the fixed ~/.relay-flow layout and ensures the root
2
+ // directory exists with owner-only permissions.
3
+ package paths
4
+
5
+ import (
6
+ "fmt"
7
+ "os"
8
+ "path/filepath"
9
+ )
10
+
11
+ // Paths is the fixed ~/.relay-flow layout. All process artifacts stay under Root.
12
+ type Paths struct {
13
+ Root string
14
+ Config string
15
+ Workflows string
16
+ Database string
17
+ Socket string
18
+ Lock string
19
+ ServerLog string
20
+ PluginLog string
21
+ }
22
+
23
+ // ForUserHome returns the Paths rooted at the current user's home directory.
24
+ func ForUserHome() (Paths, error) {
25
+ home, err := os.UserHomeDir()
26
+ if err != nil {
27
+ return Paths{}, fmt.Errorf("resolve user home: %w", err)
28
+ }
29
+ root := filepath.Join(home, ".relay-flow")
30
+ return Paths{
31
+ Root: root,
32
+ Config: filepath.Join(root, "config.yaml"),
33
+ Workflows: filepath.Join(root, "workflows"),
34
+ Database: filepath.Join(root, "state.db"),
35
+ Socket: filepath.Join(root, "server.sock"),
36
+ Lock: filepath.Join(root, "server.lock"),
37
+ ServerLog: filepath.Join(root, "server.log"),
38
+ PluginLog: filepath.Join(root, "plugin.log"),
39
+ }, nil
40
+ }
41
+
42
+ // Ensure creates Root (0700) and the Workflows subdirectory if missing.
43
+ func Ensure(p Paths) error {
44
+ if err := os.MkdirAll(p.Root, 0o700); err != nil {
45
+ return fmt.Errorf("create root %s: %w", p.Root, err)
46
+ }
47
+ if err := os.Chmod(p.Root, 0o700); err != nil {
48
+ return fmt.Errorf("chmod root %s: %w", p.Root, err)
49
+ }
50
+ if err := os.MkdirAll(p.Workflows, 0o700); err != nil {
51
+ return fmt.Errorf("create workflows %s: %w", p.Workflows, err)
52
+ }
53
+ for _, logPath := range []string{p.ServerLog, p.PluginLog} {
54
+ f, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
55
+ if err != nil {
56
+ return fmt.Errorf("create log %s: %w", logPath, err)
57
+ }
58
+ if err := f.Chmod(0o600); err != nil {
59
+ f.Close()
60
+ return fmt.Errorf("chmod log %s: %w", logPath, err)
61
+ }
62
+ if err := f.Close(); err != nil {
63
+ return fmt.Errorf("close log %s: %w", logPath, err)
64
+ }
65
+ }
66
+ return nil
67
+ }
@@ -0,0 +1,115 @@
1
+ // Package recover implements `serve --recover` (5.6, design.md decision 22,
2
+ // docs/structs-methods-interfaces.md lines 946-965). It runs once, inline,
3
+ // after the engine is open and BEFORE normal pollers start, so recovered
4
+ // runs exist before any poll cycle observes them.
5
+ //
6
+ // Steps per selected (repo, workflow, labeled-parent):
7
+ // 1. Poll active parents (task systems already exclude parents completed
8
+ // through `end`).
9
+ // 2. Filter to parents whose wf:<name> claim resolves to exactly one bound
10
+ // workflow via the router.
11
+ // 3. Skip parents carrying the stable <runID>:cancellation marker.
12
+ // 4. CloseTerminals on the run (preserves worktrees/branches/code).
13
+ // 5. EnsureMailboxes (find existing, create only missing).
14
+ // 6. ResetForRecovery on parent + mailboxes (adapter-owned; Jira moves
15
+ // mailboxes to `To Do` — core never names a status).
16
+ // 7. EnsureRun creates a fresh deterministic run with fresh NodeVisitIDs
17
+ // and processes from `start`. Old routes/nodes/visits/timers are never
18
+ // resumed.
19
+ //
20
+ // Comments, labels, worktrees, branches, and code are preserved. Recovery
21
+ // never runs automatically; database loss is never inferred.
22
+ package recover
23
+
24
+ import (
25
+ "context"
26
+ "errors"
27
+ "fmt"
28
+ "log/slog"
29
+ "sort"
30
+
31
+ "github.com/rajpopat27/relay-flow/internal/identity"
32
+ "github.com/rajpopat27/relay-flow/internal/repo"
33
+ "github.com/rajpopat27/relay-flow/internal/router"
34
+ "github.com/rajpopat27/relay-flow/internal/run"
35
+ "github.com/rajpopat27/relay-flow/internal/runner"
36
+ "github.com/rajpopat27/relay-flow/internal/task"
37
+ "github.com/rajpopat27/relay-flow/internal/workflow"
38
+ )
39
+
40
+ // MailboxSpecFor builds one spec per agent/HITL work node for a ticket. The
41
+ // engine's MailboxSpecs supplies the production description content; this
42
+ // indirection keeps recover decoupled from the engine package (which imports
43
+ // internal/run and would otherwise form a cycle).
44
+ type MailboxSpecFor func(wf *workflow.Workflow, ticketKey string) []task.MailboxSpec
45
+
46
+ // FromTaskSystem is the `serve --recover` composition. Explicit flag only;
47
+ // never automatic; database loss is never inferred.
48
+ func FromTaskSystem(ctx context.Context, repoReg *repo.Registry, rnr runner.Runner, runManager *run.RunManager, specsFor MailboxSpecFor) error {
49
+ for _, rp := range repoReg.List() {
50
+ tickets, err := rp.TaskSystem.Poll(ctx)
51
+ if err != nil {
52
+ return fmt.Errorf("repo %q poll: %w", rp.Name, err)
53
+ }
54
+ for _, ticket := range tickets {
55
+ // Use the router's exact claim semantics: zero claims or zero
56
+ // matcher hits → ErrNoMatch (not ours, skip); multiple claims
57
+ // or an unknown/unbound claim → InvalidClaimError (ambiguous
58
+ // ownership, skip with a warn). Only tickets that resolve to
59
+ // exactly one bound workflow proceed.
60
+ wf, err := router.ResolveWorkflow(rp, ticket)
61
+ if errors.Is(err, router.ErrNoMatch) {
62
+ continue
63
+ }
64
+ if err != nil {
65
+ slog.Warn("recover: skip ticket with invalid claim", "ticket", ticket.Key, "error", err)
66
+ continue
67
+ }
68
+ // Skip canceled parents: the cancellation marker is the
69
+ // task-system recovery record.
70
+ runID := identity.NewRunID(rp.Name, wf.Name, ticket.Key)
71
+ marked, err := rp.TaskSystem.HasComment(ctx, task.Target{Parent: ticket.Ref()}, run.CancellationMarker(runID))
72
+ if err != nil {
73
+ return fmt.Errorf("repo %q ticket %s check cancellation marker: %w", rp.Name, ticket.Key, err)
74
+ }
75
+ if marked {
76
+ continue
77
+ }
78
+ // Close stale agent terminals; preserve workspace/code.
79
+ if err := rnr.CloseTerminals(ctx, runner.RunSpec{
80
+ RunID: runID,
81
+ RepoName: rp.Name,
82
+ RepoPath: rp.Path,
83
+ TicketKey: ticket.Key,
84
+ }); err != nil {
85
+ return fmt.Errorf("repo %q ticket %s close terminals: %w", rp.Name, ticket.Key, err)
86
+ }
87
+ // EnsureMailboxes: find existing, create only missing. Specs are
88
+ // built by the caller (the engine supplies production content).
89
+ mailboxes, err := rp.TaskSystem.EnsureMailboxes(ctx, ticket.Ref(), wf.Name, specsFor(wf, ticket.Key))
90
+ if err != nil {
91
+ return fmt.Errorf("repo %q ticket %s ensure mailboxes: %w", rp.Name, ticket.Key, err)
92
+ }
93
+ // Deterministic ordering: sort mailbox keys so adapter-side
94
+ // effect ordering is reproducible across recoveries.
95
+ nodeNames := make([]string, 0, len(mailboxes))
96
+ for node := range mailboxes {
97
+ nodeNames = append(nodeNames, node)
98
+ }
99
+ sort.Strings(nodeNames)
100
+ mailboxList := make([]task.Mailbox, 0, len(mailboxes))
101
+ for _, node := range nodeNames {
102
+ mailboxList = append(mailboxList, mailboxes[node])
103
+ }
104
+ // ResetForRecovery: adapter-owned; resets parent + mailboxes.
105
+ if err := rp.TaskSystem.ResetForRecovery(ctx, ticket.Ref(), mailboxList, wf.TaskConfig); err != nil {
106
+ return fmt.Errorf("repo %q ticket %s reset for recovery: %w", rp.Name, ticket.Key, err)
107
+ }
108
+ // Fresh deterministic run; fresh NodeVisitIDs; from `start`.
109
+ if err := runManager.EnsureRun(ctx, rp, wf, ticket); err != nil {
110
+ return fmt.Errorf("repo %q ticket %s ensure run: %w", rp.Name, ticket.Key, err)
111
+ }
112
+ }
113
+ }
114
+ return nil
115
+ }