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,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
|
+
}
|