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,154 @@
|
|
|
1
|
+
package goworkflows_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"fmt"
|
|
6
|
+
"log/slog"
|
|
7
|
+
"strings"
|
|
8
|
+
"sync"
|
|
9
|
+
"testing"
|
|
10
|
+
"time"
|
|
11
|
+
|
|
12
|
+
"github.com/rajpopat27/relay-flow/internal/execution/goworkflows"
|
|
13
|
+
"github.com/rajpopat27/relay-flow/internal/workflow"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
// 9.7: logging is observable behavior. Capture the slog default handler's
|
|
17
|
+
// output for one full node transition (run created → node entered → report
|
|
18
|
+
// persisted → summary written → feedback written → mailbox completed →
|
|
19
|
+
// run completed) and assert the ticket/runID/node attrs appear on each
|
|
20
|
+
// transition line. Uses the seam-(a) fakes and seam-(b) temp SQLite engine
|
|
21
|
+
// that every other engine test uses — no new production hooks.
|
|
22
|
+
func TestNodeTransitionLogsCarryTicketRunNodeAttrs(t *testing.T) {
|
|
23
|
+
h := newCaptureHandler()
|
|
24
|
+
prev := slog.Default()
|
|
25
|
+
slog.SetDefault(slog.New(h))
|
|
26
|
+
t.Cleanup(func() { slog.SetDefault(prev) })
|
|
27
|
+
|
|
28
|
+
log := newEventLog()
|
|
29
|
+
sys := newFakeTaskSystem(log)
|
|
30
|
+
engine := newEngine(t, goworkflows.Dependencies{
|
|
31
|
+
Repos: repoRegistryWith("payments", sys),
|
|
32
|
+
Runner: newFakeRunner(log), Harness: newFakeHarness(log),
|
|
33
|
+
})
|
|
34
|
+
rid, _ := startRun(engine, linearWorkflow(false))
|
|
35
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
36
|
+
r, _ := engine.GetRun(context.Background(), rid)
|
|
37
|
+
return r.CurrentNode == "coding" && r.CurrentNodeVisitID != ""
|
|
38
|
+
})
|
|
39
|
+
r, _ := engine.GetRun(context.Background(), rid)
|
|
40
|
+
visit := r.CurrentNodeVisitID
|
|
41
|
+
|
|
42
|
+
// coding → end with a feedback-carrying report (NextStep=end means
|
|
43
|
+
// feedback is all None; use a coding→coding failure revisit first so
|
|
44
|
+
// feedback is actually written to a selected next mailbox, then a
|
|
45
|
+
// final coding→end success report).
|
|
46
|
+
loop := successReport("coding")
|
|
47
|
+
loop.Status = workflow.OutcomeFailure
|
|
48
|
+
if _, err := engine.SubmitReport(context.Background(), reportRequest(rid, "coding", loop)); err != nil {
|
|
49
|
+
t.Fatal(err)
|
|
50
|
+
}
|
|
51
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
52
|
+
r, _ := engine.GetRun(context.Background(), rid)
|
|
53
|
+
return r.CurrentNodeVisitID != "" && r.CurrentNodeVisitID != visit
|
|
54
|
+
})
|
|
55
|
+
r, _ = engine.GetRun(context.Background(), rid)
|
|
56
|
+
if _, err := engine.SubmitReport(context.Background(), reportRequest(rid, "coding", successReport("end"))); err != nil {
|
|
57
|
+
t.Fatal(err)
|
|
58
|
+
}
|
|
59
|
+
// Wait for the terminal-state log line itself (not just the projection
|
|
60
|
+
// state): the ProjectionUpdateState activity writes the state and the
|
|
61
|
+
// log line in one call, and waiting on state alone races the log.
|
|
62
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
63
|
+
for _, ln := range h.lines() {
|
|
64
|
+
if ln["msg"] == "run completed" {
|
|
65
|
+
return true
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return false
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
wantTicket := "PAY-101"
|
|
72
|
+
wantRun := string(rid)
|
|
73
|
+
|
|
74
|
+
// The transition lines that must carry the required attrs.
|
|
75
|
+
type expectation struct {
|
|
76
|
+
msg string
|
|
77
|
+
needsNode bool
|
|
78
|
+
needsVisit bool
|
|
79
|
+
}
|
|
80
|
+
expect := []expectation{
|
|
81
|
+
{"run created", false, false},
|
|
82
|
+
{"node entered", true, true},
|
|
83
|
+
{"report persisted", true, true},
|
|
84
|
+
{"summary written", false, true}, // summary carries nodeVisitID via marker
|
|
85
|
+
{"feedback written", true, true}, // feedback carries the selected next node
|
|
86
|
+
{"mailbox completed", true, false},
|
|
87
|
+
{"run completed", false, false},
|
|
88
|
+
}
|
|
89
|
+
lines := h.lines()
|
|
90
|
+
for _, e := range expect {
|
|
91
|
+
var found map[string]string
|
|
92
|
+
for _, ln := range lines {
|
|
93
|
+
if ln["msg"] == e.msg {
|
|
94
|
+
found = ln
|
|
95
|
+
break
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if found == nil {
|
|
99
|
+
t.Fatalf("missing log line %q; captured %d lines:\n%s", e.msg, len(lines), renderLines(lines))
|
|
100
|
+
}
|
|
101
|
+
if found["ticket"] != wantTicket {
|
|
102
|
+
t.Errorf("%s: ticket = %q, want %q", e.msg, found["ticket"], wantTicket)
|
|
103
|
+
}
|
|
104
|
+
if found["runID"] != wantRun {
|
|
105
|
+
t.Errorf("%s: runID = %q, want %q", e.msg, found["runID"], wantRun)
|
|
106
|
+
}
|
|
107
|
+
if e.needsNode && found["node"] == "" {
|
|
108
|
+
t.Errorf("%s: missing node attr", e.msg)
|
|
109
|
+
}
|
|
110
|
+
if e.needsVisit && found["nodeVisitID"] == "" {
|
|
111
|
+
t.Errorf("%s: missing nodeVisitID attr", e.msg)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
func renderLines(lines []map[string]string) string {
|
|
117
|
+
var b strings.Builder
|
|
118
|
+
for _, ln := range lines {
|
|
119
|
+
fmt.Fprintf(&b, " %v\n", ln)
|
|
120
|
+
}
|
|
121
|
+
return b.String()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// captureHandler is an slog.Handler that records each record's message
|
|
125
|
+
// and attrs. Not safe for production use; tests only.
|
|
126
|
+
type captureHandler struct {
|
|
127
|
+
mu sync.Mutex
|
|
128
|
+
recs []map[string]string
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
func newCaptureHandler() *captureHandler { return &captureHandler{} }
|
|
132
|
+
|
|
133
|
+
func (h *captureHandler) Enabled(context.Context, slog.Level) bool { return true }
|
|
134
|
+
|
|
135
|
+
func (h *captureHandler) Handle(_ context.Context, r slog.Record) error {
|
|
136
|
+
m := map[string]string{"msg": r.Message, "level": r.Level.String()}
|
|
137
|
+
r.Attrs(func(a slog.Attr) bool {
|
|
138
|
+
m[a.Key] = fmt.Sprint(a.Value.Any())
|
|
139
|
+
return true
|
|
140
|
+
})
|
|
141
|
+
h.mu.Lock()
|
|
142
|
+
h.recs = append(h.recs, m)
|
|
143
|
+
h.mu.Unlock()
|
|
144
|
+
return nil
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
func (h *captureHandler) WithAttrs([]slog.Attr) slog.Handler { return h }
|
|
148
|
+
func (h *captureHandler) WithGroup(string) slog.Handler { return h }
|
|
149
|
+
|
|
150
|
+
func (h *captureHandler) lines() []map[string]string {
|
|
151
|
+
h.mu.Lock()
|
|
152
|
+
defer h.mu.Unlock()
|
|
153
|
+
return append([]map[string]string(nil), h.recs...)
|
|
154
|
+
}
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
package goworkflows_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"strings"
|
|
6
|
+
"testing"
|
|
7
|
+
"time"
|
|
8
|
+
|
|
9
|
+
"github.com/rajpopat27/relay-flow/internal/execution/goworkflows"
|
|
10
|
+
"github.com/rajpopat27/relay-flow/internal/identity"
|
|
11
|
+
"github.com/rajpopat27/relay-flow/internal/run"
|
|
12
|
+
"github.com/rajpopat27/relay-flow/internal/task"
|
|
13
|
+
"github.com/rajpopat27/relay-flow/internal/workflow"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
// 3.27-3.28: mailbox lifecycle at the run level per specs/node-mailboxes.
|
|
17
|
+
// Fakes in fakes_test.go record labels, specs, statuses, and comments.
|
|
18
|
+
|
|
19
|
+
func threeNodeWorkflow() workflow.Workflow {
|
|
20
|
+
return workflow.Workflow{
|
|
21
|
+
Name: "threeNode", Repos: []string{"payments"},
|
|
22
|
+
Nodes: map[string]workflow.Node{
|
|
23
|
+
"start": {OnSuccess: []workflow.Route{{Target: "exploration"}}},
|
|
24
|
+
"exploration": {
|
|
25
|
+
Type: workflow.NodeAgent, Agent: "build", Description: "explore the code",
|
|
26
|
+
OnSuccess: []workflow.Route{{Target: "coding", When: "explored"}},
|
|
27
|
+
OnFailure: []workflow.Route{{Target: "exploration"}},
|
|
28
|
+
},
|
|
29
|
+
"coding": {
|
|
30
|
+
Type: workflow.NodeAgent, Agent: "build", Description: "write the code",
|
|
31
|
+
OnSuccess: []workflow.Route{{Target: "review", When: "coded"}},
|
|
32
|
+
OnFailure: []workflow.Route{{Target: "coding"}},
|
|
33
|
+
},
|
|
34
|
+
"review": {
|
|
35
|
+
Type: workflow.NodeHITL, Agent: "reviewer", Description: "review the diff",
|
|
36
|
+
OnSuccess: []workflow.Route{{Target: "end"}},
|
|
37
|
+
OnFailure: []workflow.Route{{Target: "coding", When: "changes needed"}},
|
|
38
|
+
},
|
|
39
|
+
"end": {},
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
func TestMailboxesEnsuredForWorkNodesOnly(t *testing.T) {
|
|
45
|
+
log := newEventLog()
|
|
46
|
+
sys := newFakeTaskSystem(log)
|
|
47
|
+
engine := newEngine(t, goworkflows.Dependencies{
|
|
48
|
+
Repos: repoRegistryWith("payments", sys), Runner: newFakeRunner(log), Harness: newFakeHarness(log),
|
|
49
|
+
})
|
|
50
|
+
rid, _ := startRun(engine, threeNodeWorkflow())
|
|
51
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
52
|
+
r, _ := engine.GetRun(context.Background(), rid)
|
|
53
|
+
return r.CurrentNode == "exploration"
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
if len(sys.specs) == 0 {
|
|
57
|
+
t.Fatal("EnsureMailboxes never called")
|
|
58
|
+
}
|
|
59
|
+
nodes := map[string]task.MailboxSpec{}
|
|
60
|
+
for _, sp := range sys.specs {
|
|
61
|
+
nodes[sp.Node] = sp
|
|
62
|
+
if sp.Node == "start" || sp.Node == "end" {
|
|
63
|
+
t.Fatalf("mailbox spec for reserved node %q; start/end get none", sp.Node)
|
|
64
|
+
}
|
|
65
|
+
if sp.Title != "PAY-101:"+sp.Node {
|
|
66
|
+
t.Fatalf("mailbox title = %q, want <ticket>:<node>", sp.Title)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// Each node's description must carry its type, agent, every legal success
|
|
70
|
+
// and failure route target, and the explanation (When) for each route that
|
|
71
|
+
// has one.
|
|
72
|
+
wantRoutes := map[string]struct {
|
|
73
|
+
agent string
|
|
74
|
+
ntype string
|
|
75
|
+
targets []string // success + failure
|
|
76
|
+
whens []string // configured route explanations
|
|
77
|
+
}{
|
|
78
|
+
"exploration": {agent: "build", ntype: "agent", targets: []string{"coding", "exploration"}, whens: []string{"explored"}},
|
|
79
|
+
"coding": {agent: "build", ntype: "agent", targets: []string{"review", "coding"}, whens: []string{"coded"}},
|
|
80
|
+
"review": {agent: "reviewer", ntype: "hitl", targets: []string{"end", "coding"}, whens: []string{"changes needed"}},
|
|
81
|
+
}
|
|
82
|
+
for node, want := range wantRoutes {
|
|
83
|
+
sp, ok := nodes[node]
|
|
84
|
+
if !ok {
|
|
85
|
+
t.Fatalf("no mailbox spec for work node %q", node)
|
|
86
|
+
}
|
|
87
|
+
d := sp.Description
|
|
88
|
+
if !strings.Contains(d, node) {
|
|
89
|
+
t.Fatalf("%s description lacks node name: %q", node, d)
|
|
90
|
+
}
|
|
91
|
+
if !strings.Contains(d, "PAY-101") {
|
|
92
|
+
t.Fatalf("%s description lacks parent identity: %q", node, d)
|
|
93
|
+
}
|
|
94
|
+
if !strings.Contains(d, want.agent) {
|
|
95
|
+
t.Fatalf("%s description lacks agent %q: %q", node, want.agent, d)
|
|
96
|
+
}
|
|
97
|
+
if !strings.Contains(d, want.ntype) {
|
|
98
|
+
t.Fatalf("%s description lacks node type %q: %q", node, want.ntype, d)
|
|
99
|
+
}
|
|
100
|
+
for _, target := range want.targets {
|
|
101
|
+
if !strings.Contains(d, target) {
|
|
102
|
+
t.Fatalf("%s description lacks legal route target %q: %q", node, target, d)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
for _, when := range want.whens {
|
|
106
|
+
if !strings.Contains(d, when) {
|
|
107
|
+
t.Fatalf("%s description lacks route explanation %q: %q", node, when, d)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
for _, required := range []string{"Required report format:", "STATUS:", "COMMITS:", "FEEDBACK:"} {
|
|
111
|
+
if !strings.Contains(d, required) {
|
|
112
|
+
t.Fatalf("%s description lacks %q: %q", node, required, d)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// The exploration description carries its node work text.
|
|
117
|
+
if !strings.Contains(nodes["exploration"].Description, "explore the code") {
|
|
118
|
+
t.Fatalf("exploration description lacks node work: %q", nodes["exploration"].Description)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
func TestMailboxCarriesWorkflowLabel(t *testing.T) {
|
|
123
|
+
log := newEventLog()
|
|
124
|
+
sys := newFakeTaskSystem(log)
|
|
125
|
+
engine := newEngine(t, goworkflows.Dependencies{
|
|
126
|
+
Repos: repoRegistryWith("payments", sys), Runner: newFakeRunner(log), Harness: newFakeHarness(log),
|
|
127
|
+
})
|
|
128
|
+
rid, _ := startRun(engine, threeNodeWorkflow())
|
|
129
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
130
|
+
r, _ := engine.GetRun(context.Background(), rid)
|
|
131
|
+
return r.CurrentNode == "exploration"
|
|
132
|
+
})
|
|
133
|
+
for node, mb := range sys.mailboxesSnapshot() {
|
|
134
|
+
labels := sys.labelsFor(mb.Key)
|
|
135
|
+
found := false
|
|
136
|
+
for _, l := range labels {
|
|
137
|
+
if l == "wf:threeNode" {
|
|
138
|
+
found = true
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if !found {
|
|
142
|
+
t.Fatalf("mailbox %s (node %s) missing wf:threeNode label: %v", mb.Key, node, labels)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
func TestRevisitReusesSameMailbox(t *testing.T) {
|
|
148
|
+
log := newEventLog()
|
|
149
|
+
sys := newFakeTaskSystem(log)
|
|
150
|
+
engine := newEngine(t, goworkflows.Dependencies{
|
|
151
|
+
Repos: repoRegistryWith("payments", sys), Runner: newFakeRunner(log), Harness: newFakeHarness(log),
|
|
152
|
+
})
|
|
153
|
+
rid, _ := startRun(engine, threeNodeWorkflow())
|
|
154
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
155
|
+
r, _ := engine.GetRun(context.Background(), rid)
|
|
156
|
+
return r.CurrentNode == "exploration"
|
|
157
|
+
})
|
|
158
|
+
r, _ := engine.GetRun(context.Background(), rid)
|
|
159
|
+
firstVisit := r.CurrentNodeVisitID
|
|
160
|
+
firstMailbox, _ := sys.mailboxFor("PAY-101", "exploration")
|
|
161
|
+
|
|
162
|
+
// exploration failure -> exploration again (revisit).
|
|
163
|
+
fail := successReport("exploration")
|
|
164
|
+
fail.Status = workflow.OutcomeFailure
|
|
165
|
+
fail.Feedback = workflow.Feedback{
|
|
166
|
+
ReasonForNextStep: "more", RequiredActions: "explore more",
|
|
167
|
+
RelevantContext: "None", ExpectedResult: "done",
|
|
168
|
+
}
|
|
169
|
+
if _, err := engine.SubmitReport(context.Background(), reportRequest(rid, "exploration", fail)); err != nil {
|
|
170
|
+
t.Fatal(err)
|
|
171
|
+
}
|
|
172
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
173
|
+
r, _ := engine.GetRun(context.Background(), rid)
|
|
174
|
+
return r.CurrentNode == "exploration" && r.CurrentNodeVisitID != "" && r.CurrentNodeVisitID != firstVisit
|
|
175
|
+
})
|
|
176
|
+
// Same mailbox reused (found, not recreated); new visit ID differs.
|
|
177
|
+
r2, _ := engine.GetRun(context.Background(), rid)
|
|
178
|
+
if r2.CurrentNodeVisitID == firstVisit {
|
|
179
|
+
t.Fatal("revisit kept the same nodeVisitID; each entry must get a fresh one")
|
|
180
|
+
}
|
|
181
|
+
if cur, _ := sys.mailboxFor("PAY-101", "exploration"); cur != firstMailbox {
|
|
182
|
+
t.Fatal("revisit created a new exploration mailbox instead of reusing it")
|
|
183
|
+
}
|
|
184
|
+
if log.count("createMailbox:PAY-101:exploration") != 1 {
|
|
185
|
+
t.Fatalf("exploration mailbox created %d times, want 1 (reused on revisit)", log.count("createMailbox:PAY-101:exploration"))
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
func TestSummaryMarkerAndContentOnCurrentMailbox(t *testing.T) {
|
|
190
|
+
log := newEventLog()
|
|
191
|
+
sys := newFakeTaskSystem(log)
|
|
192
|
+
engine := newEngine(t, goworkflows.Dependencies{
|
|
193
|
+
Repos: repoRegistryWith("payments", sys), Runner: newFakeRunner(log), Harness: newFakeHarness(log),
|
|
194
|
+
})
|
|
195
|
+
rid, _ := startRun(engine, linearWorkflow(false))
|
|
196
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
197
|
+
r, _ := engine.GetRun(context.Background(), rid)
|
|
198
|
+
return r.CurrentNode == "coding"
|
|
199
|
+
})
|
|
200
|
+
r, _ := engine.GetRun(context.Background(), rid)
|
|
201
|
+
if _, err := engine.SubmitReport(context.Background(), reportRequest(rid, "coding", successReport("end"))); err != nil {
|
|
202
|
+
t.Fatal(err)
|
|
203
|
+
}
|
|
204
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
205
|
+
r, _ := engine.GetRun(context.Background(), rid)
|
|
206
|
+
return r.State == run.StateCompleted
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
summaries := sys.commentBodies("PAY-101-coding")
|
|
210
|
+
if len(summaries) != 1 {
|
|
211
|
+
t.Fatalf("coding summaries = %d, want 1", len(summaries))
|
|
212
|
+
}
|
|
213
|
+
s := summaries[0]
|
|
214
|
+
// Human-readable summary content from the report.
|
|
215
|
+
for _, want := range []string{"done", "COMMITS:", "abc123"} {
|
|
216
|
+
if !strings.Contains(s.Body, want) {
|
|
217
|
+
t.Fatalf("summary body lacks %q: %q", want, s.Body)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
// Stable marker derived from nodeVisitID and comment type.
|
|
221
|
+
if !strings.Contains(s.Marker, string(r.CurrentNodeVisitID)) {
|
|
222
|
+
t.Fatalf("summary marker %q not derived from nodeVisitID %q", s.Marker, r.CurrentNodeVisitID)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
func TestSummaryCurrentFeedbackSelectedNextOnly(t *testing.T) {
|
|
227
|
+
log := newEventLog()
|
|
228
|
+
sys := newFakeTaskSystem(log)
|
|
229
|
+
engine := newEngine(t, goworkflows.Dependencies{
|
|
230
|
+
Repos: repoRegistryWith("payments", sys), Runner: newFakeRunner(log), Harness: newFakeHarness(log),
|
|
231
|
+
})
|
|
232
|
+
rid, _ := startRun(engine, threeNodeWorkflow())
|
|
233
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
234
|
+
r, _ := engine.GetRun(context.Background(), rid)
|
|
235
|
+
return r.CurrentNode == "exploration"
|
|
236
|
+
})
|
|
237
|
+
report := successReport("coding")
|
|
238
|
+
report.Feedback = workflow.Feedback{
|
|
239
|
+
ReasonForNextStep: "explored", RequiredActions: "code it",
|
|
240
|
+
RelevantContext: "ctx", ExpectedResult: "working code",
|
|
241
|
+
}
|
|
242
|
+
if _, err := engine.SubmitReport(context.Background(), reportRequest(rid, "exploration", report)); err != nil {
|
|
243
|
+
t.Fatal(err)
|
|
244
|
+
}
|
|
245
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
246
|
+
r, _ := engine.GetRun(context.Background(), rid)
|
|
247
|
+
return r.CurrentNode == "coding"
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
if len(sys.commentBodies("PAY-101-exploration")) == 0 {
|
|
251
|
+
t.Fatal("summary not written to current exploration mailbox")
|
|
252
|
+
}
|
|
253
|
+
// Feedback to the selected next mailbox carries every feedback field.
|
|
254
|
+
fbs := sys.commentBodies("PAY-101-coding")
|
|
255
|
+
if len(fbs) == 0 {
|
|
256
|
+
t.Fatal("feedback not written to selected coding mailbox")
|
|
257
|
+
}
|
|
258
|
+
for _, want := range []string{"Feedback from exploration", "COMMITS:", "abc123", "explored", "code it", "ctx", "working code"} {
|
|
259
|
+
if !strings.Contains(fbs[0].Body, want) {
|
|
260
|
+
t.Fatalf("feedback body missing %q: %q", want, fbs[0].Body)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
// Unrelated mailbox (review) gets nothing.
|
|
264
|
+
if len(sys.commentBodies("PAY-101-review")) != 0 {
|
|
265
|
+
t.Fatal("feedback written to unrelated review mailbox")
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// 3.28: end/mailbox behavior, manual status not routing, HITL lifecycle.
|
|
270
|
+
|
|
271
|
+
func TestManualMailboxStatusDoesNotRouteGraph(t *testing.T) {
|
|
272
|
+
log := newEventLog()
|
|
273
|
+
sys := newFakeTaskSystem(log)
|
|
274
|
+
engine := newEngine(t, goworkflows.Dependencies{
|
|
275
|
+
Repos: repoRegistryWith("payments", sys), Runner: newFakeRunner(log), Harness: newFakeHarness(log),
|
|
276
|
+
})
|
|
277
|
+
rid, _ := startRun(engine, linearWorkflow(false))
|
|
278
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
279
|
+
r, _ := engine.GetRun(context.Background(), rid)
|
|
280
|
+
return r.CurrentNode == "coding"
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
// A human moves the coding mailbox to Done without a structured report.
|
|
284
|
+
sys.setMailboxStatus("PAY-101-coding", "Done")
|
|
285
|
+
|
|
286
|
+
// Reconcile/poll (EnsureRun) must not infer success or route the graph.
|
|
287
|
+
wf := linearWorkflow(false)
|
|
288
|
+
if _, err := engine.EnsureRun(context.Background(), run.Start{
|
|
289
|
+
ID: rid, Repo: "payments", RepoPath: "/srv/payments", Workflow: wf,
|
|
290
|
+
Ticket: task.TicketRef{ID: "1", Key: "PAY-101"},
|
|
291
|
+
}); err != nil {
|
|
292
|
+
t.Fatal(err)
|
|
293
|
+
}
|
|
294
|
+
time.Sleep(500 * time.Millisecond)
|
|
295
|
+
r, _ := engine.GetRun(context.Background(), rid)
|
|
296
|
+
if r.CurrentNode != "coding" {
|
|
297
|
+
t.Fatalf("graph advanced to %q on a manual mailbox status change", r.CurrentNode)
|
|
298
|
+
}
|
|
299
|
+
if r.State == run.StateCompleted {
|
|
300
|
+
t.Fatal("run completed without a structured report")
|
|
301
|
+
}
|
|
302
|
+
// No new node terminal scheduled.
|
|
303
|
+
if log.count("ensureTerminal:PAY-101:review") != 0 {
|
|
304
|
+
t.Fatal("manual mailbox status change scheduled the next node")
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
func TestHITLUsesSameMailboxLifecycle(t *testing.T) {
|
|
309
|
+
log := newEventLog()
|
|
310
|
+
sys := newFakeTaskSystem(log)
|
|
311
|
+
engine := newEngine(t, goworkflows.Dependencies{
|
|
312
|
+
Repos: repoRegistryWith("payments", sys), Runner: newFakeRunner(log), Harness: newFakeHarness(log),
|
|
313
|
+
})
|
|
314
|
+
wf := workflow.Workflow{
|
|
315
|
+
Name: "hitlFlow", Repos: []string{"payments"},
|
|
316
|
+
Nodes: map[string]workflow.Node{
|
|
317
|
+
"start": {OnSuccess: []workflow.Route{{Target: "review"}}},
|
|
318
|
+
"review": {
|
|
319
|
+
Type: workflow.NodeHITL, Agent: "reviewer", Description: "review",
|
|
320
|
+
OnSuccess: []workflow.Route{{Target: "end"}},
|
|
321
|
+
OnFailure: []workflow.Route{{Target: "review"}},
|
|
322
|
+
},
|
|
323
|
+
"end": {},
|
|
324
|
+
},
|
|
325
|
+
}
|
|
326
|
+
rid := identity.NewRunID("payments", "hitlFlow", "PAY-101")
|
|
327
|
+
if _, err := engine.EnsureRun(context.Background(), run.Start{
|
|
328
|
+
ID: rid, Repo: "payments", RepoPath: "/srv/payments", Workflow: wf,
|
|
329
|
+
Ticket: task.TicketRef{ID: "1", Key: "PAY-101"},
|
|
330
|
+
}); err != nil {
|
|
331
|
+
t.Fatal(err)
|
|
332
|
+
}
|
|
333
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
334
|
+
r, _ := engine.GetRun(context.Background(), rid)
|
|
335
|
+
return r.CurrentNode == "review"
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
// HITL review mailbox exists with the wf label.
|
|
339
|
+
mb, ok := sys.mailboxFor("PAY-101", "review")
|
|
340
|
+
if !ok {
|
|
341
|
+
t.Fatal("no HITL review mailbox ensured")
|
|
342
|
+
}
|
|
343
|
+
found := false
|
|
344
|
+
for _, l := range sys.labelsFor(mb.Key) {
|
|
345
|
+
if l == "wf:hitlFlow" {
|
|
346
|
+
found = true
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
if !found {
|
|
350
|
+
t.Fatalf("HITL mailbox missing wf label: %v", sys.labelsFor(mb.Key))
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// A valid HITL report advances the run through the same lifecycle.
|
|
354
|
+
if _, err := engine.SubmitReport(context.Background(), reportRequest(rid, "review", successReport("end"))); err != nil {
|
|
355
|
+
t.Fatal(err)
|
|
356
|
+
}
|
|
357
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
358
|
+
r, _ := engine.GetRun(context.Background(), rid)
|
|
359
|
+
return r.State == run.StateCompleted
|
|
360
|
+
})
|
|
361
|
+
if len(sys.commentBodies(mb.Key)) == 0 {
|
|
362
|
+
t.Fatal("HITL summary not recorded in its mailbox")
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
func TestRecoveryReusesMailboxesCreatesOnlyMissing(t *testing.T) {
|
|
367
|
+
// Recovery mailbox semantics through the documented task.System
|
|
368
|
+
// primitives only: EnsureMailboxes finds existing and creates only
|
|
369
|
+
// missing, then ResetForRecovery resets them to To Do while preserving
|
|
370
|
+
// comments/labels. The full serve --recover composition is section 5.6.
|
|
371
|
+
log := newEventLog()
|
|
372
|
+
sys := newFakeTaskSystem(log)
|
|
373
|
+
parent := task.TicketRef{ID: "1", Key: "PAY-101"}
|
|
374
|
+
|
|
375
|
+
// Pre-existing exploration mailbox (with a comment and label to keep).
|
|
376
|
+
sys.seedMailbox("PAY-101", task.Mailbox{ID: "mb-exploration", Key: "PAY-101-exploration", Node: "exploration"}, []string{"wf:threeNode"})
|
|
377
|
+
sys.Comment(context.Background(), task.Target{Parent: parent, Mailbox: mailboxPtr(sys, "PAY-101", "exploration")}, "old summary", "old")
|
|
378
|
+
|
|
379
|
+
specs := []task.MailboxSpec{
|
|
380
|
+
{Node: "exploration", Title: "PAY-101:exploration", Description: "explore"},
|
|
381
|
+
{Node: "coding", Title: "PAY-101:coding", Description: "code"},
|
|
382
|
+
{Node: "review", Title: "PAY-101:review", Description: "review"},
|
|
383
|
+
}
|
|
384
|
+
mbs, err := sys.EnsureMailboxes(context.Background(), parent, "threeNode", specs)
|
|
385
|
+
if err != nil {
|
|
386
|
+
t.Fatal(err)
|
|
387
|
+
}
|
|
388
|
+
if len(mbs) != 3 {
|
|
389
|
+
t.Fatalf("EnsureMailboxes = %d, want complete map of 3", len(mbs))
|
|
390
|
+
}
|
|
391
|
+
if log.count("foundMailbox:PAY-101:exploration") != 1 {
|
|
392
|
+
t.Fatal("existing exploration mailbox not found/reused")
|
|
393
|
+
}
|
|
394
|
+
if log.count("createMailbox:PAY-101:exploration") != 0 {
|
|
395
|
+
t.Fatal("existing exploration mailbox recreated")
|
|
396
|
+
}
|
|
397
|
+
if log.count("createMailbox:PAY-101:coding") != 1 || log.count("createMailbox:PAY-101:review") != 1 {
|
|
398
|
+
t.Fatal("missing mailboxes not created exactly once")
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Reset to To Do, preserving the existing comment/label.
|
|
402
|
+
var mbList []task.Mailbox
|
|
403
|
+
for _, mb := range mbs {
|
|
404
|
+
mbList = append(mbList, mb)
|
|
405
|
+
}
|
|
406
|
+
if err := sys.ResetForRecovery(context.Background(), parent, mbList, nil); err != nil {
|
|
407
|
+
t.Fatal(err)
|
|
408
|
+
}
|
|
409
|
+
if sys.mailboxStatusOf("PAY-101-exploration") != "To Do" {
|
|
410
|
+
t.Fatal("mailbox not reset to To Do")
|
|
411
|
+
}
|
|
412
|
+
if len(sys.commentBodies("PAY-101-exploration")) == 0 {
|
|
413
|
+
t.Fatal("reset dropped existing mailbox comment")
|
|
414
|
+
}
|
|
415
|
+
if len(sys.labelsFor("PAY-101-exploration")) == 0 {
|
|
416
|
+
t.Fatal("reset dropped mailbox wf label")
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
func mailboxPtr(sys *fakeTaskSystem, parentKey, node string) *task.Mailbox {
|
|
421
|
+
mb, _ := sys.mailboxFor(parentKey, node)
|
|
422
|
+
return &mb
|
|
423
|
+
}
|