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,1135 @@
|
|
|
1
|
+
package main
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"path/filepath"
|
|
7
|
+
"sort"
|
|
8
|
+
"strings"
|
|
9
|
+
"sync"
|
|
10
|
+
"testing"
|
|
11
|
+
"time"
|
|
12
|
+
|
|
13
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
14
|
+
"github.com/rajpopat27/relay-flow/internal/execution/goworkflows"
|
|
15
|
+
"github.com/rajpopat27/relay-flow/internal/harness"
|
|
16
|
+
"github.com/rajpopat27/relay-flow/internal/identity"
|
|
17
|
+
"github.com/rajpopat27/relay-flow/internal/repo"
|
|
18
|
+
runsvc "github.com/rajpopat27/relay-flow/internal/run"
|
|
19
|
+
"github.com/rajpopat27/relay-flow/internal/runner"
|
|
20
|
+
"github.com/rajpopat27/relay-flow/internal/task"
|
|
21
|
+
"github.com/rajpopat27/relay-flow/internal/workflow"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
const scenarioTaskPlugin = "scenario-e2e-task"
|
|
25
|
+
|
|
26
|
+
var (
|
|
27
|
+
scenarioFactoryMu sync.Mutex
|
|
28
|
+
scenarioFactorySystem task.System
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
func init() {
|
|
32
|
+
task.Register(scenarioTaskPlugin, task.Factory{
|
|
33
|
+
RequiredRepoKeys: func() []string { return nil },
|
|
34
|
+
TaskScopeKey: func(config.RawValues, config.RawValues) (string, error) {
|
|
35
|
+
return "scenario-scope", nil
|
|
36
|
+
},
|
|
37
|
+
New: func(context.Context, task.RepoSpec) (task.System, error) {
|
|
38
|
+
scenarioFactoryMu.Lock()
|
|
39
|
+
defer scenarioFactoryMu.Unlock()
|
|
40
|
+
if scenarioFactorySystem == nil {
|
|
41
|
+
return nil, errors.New("scenario task system not configured")
|
|
42
|
+
}
|
|
43
|
+
return scenarioFactorySystem, nil
|
|
44
|
+
},
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
func setScenarioFactorySystem(system task.System) {
|
|
49
|
+
scenarioFactoryMu.Lock()
|
|
50
|
+
scenarioFactorySystem = system
|
|
51
|
+
scenarioFactoryMu.Unlock()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// These scenarios exercise the same composition chain as serve:
|
|
55
|
+
// RepoPoller/handleBatch -> RunManager -> real go-workflows SQLite engine.
|
|
56
|
+
// The only replacements are the documented task, runner, and harness seams.
|
|
57
|
+
|
|
58
|
+
func TestScenarioHappyPath(t *testing.T) {
|
|
59
|
+
f := newScenarioFixture(t)
|
|
60
|
+
f.pollOnce()
|
|
61
|
+
f.waitNode("implement")
|
|
62
|
+
|
|
63
|
+
wantID := identity.NewRunID(scenarioRepo, scenarioWorkflowName, scenarioTicket)
|
|
64
|
+
if f.runID != wantID {
|
|
65
|
+
t.Fatalf("run ID = %q, want deterministic %q", f.runID, wantID)
|
|
66
|
+
}
|
|
67
|
+
assertBefore(t, f.log.all(), "claim:TEST-1:scenarioFlow", "run-created:"+string(f.runID))
|
|
68
|
+
assertMailboxDefinitions(t, f.tasks)
|
|
69
|
+
assertLaunch(t, f, "implement", workflow.NodeAgent)
|
|
70
|
+
|
|
71
|
+
f.submit(workflow.OutcomeSuccess, "verify")
|
|
72
|
+
f.waitNode("verify")
|
|
73
|
+
assertTransitionOrder(t, f.log.all(), "implement", "verify")
|
|
74
|
+
assertLaunch(t, f, "verify", workflow.NodeAgent)
|
|
75
|
+
|
|
76
|
+
f.submit(workflow.OutcomeSuccess, "pr-review")
|
|
77
|
+
f.waitNode("pr-review")
|
|
78
|
+
assertTransitionOrder(t, f.log.all(), "verify", "pr-review")
|
|
79
|
+
// The real launch metadata selects the production plugin's HITL silence
|
|
80
|
+
// path. The TypeScript plugin tests drive that path directly; this Go
|
|
81
|
+
// scenario does not invent a second nudge implementation.
|
|
82
|
+
assertLaunch(t, f, "pr-review", workflow.NodeHITL)
|
|
83
|
+
|
|
84
|
+
f.submit(workflow.OutcomeSuccess, "end")
|
|
85
|
+
f.waitCompleted()
|
|
86
|
+
if got := f.runner.cleanupCount(); got != 1 {
|
|
87
|
+
t.Fatalf("CleanupRun calls = %d, want 1 with explicit cleanup policy", got)
|
|
88
|
+
}
|
|
89
|
+
assertExactHappyEffects(t, f)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
func TestScenarioHITLRejectLoop(t *testing.T) {
|
|
93
|
+
f := newScenarioFixture(t)
|
|
94
|
+
f.pollOnce()
|
|
95
|
+
f.waitNode("implement")
|
|
96
|
+
f.submit(workflow.OutcomeSuccess, "verify")
|
|
97
|
+
f.waitNode("verify")
|
|
98
|
+
f.submit(workflow.OutcomeSuccess, "pr-review")
|
|
99
|
+
f.waitNode("pr-review")
|
|
100
|
+
|
|
101
|
+
firstImplementVisit := f.harness.launch("implement").NodeVisitID
|
|
102
|
+
f.submit(workflow.OutcomeFailure, "implement")
|
|
103
|
+
f.waitNodeWithNewVisit("implement", firstImplementVisit)
|
|
104
|
+
if f.tasks.mailboxCreateCount("implement") != 1 {
|
|
105
|
+
t.Fatal("reject loop created a second implement mailbox")
|
|
106
|
+
}
|
|
107
|
+
if got := f.tasks.commentCount("implement", "feedback"); got != 1 {
|
|
108
|
+
t.Fatalf("reject feedback on reopened implement mailbox = %d, want 1", got)
|
|
109
|
+
}
|
|
110
|
+
if got := f.runner.launchCount("TEST-1:implement"); got != 2 {
|
|
111
|
+
t.Fatalf("implement terminal launches = %d, want 2 with explicit terminal checkpointing", got)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
f.submit(workflow.OutcomeSuccess, "verify")
|
|
115
|
+
f.waitNode("verify")
|
|
116
|
+
f.submit(workflow.OutcomeSuccess, "pr-review")
|
|
117
|
+
f.waitNode("pr-review")
|
|
118
|
+
f.submit(workflow.OutcomeSuccess, "end")
|
|
119
|
+
f.waitCompleted()
|
|
120
|
+
|
|
121
|
+
if got := f.tasks.commentCount("implement", "summary"); got != 2 {
|
|
122
|
+
t.Fatalf("implement summaries = %d, want one per pass", got)
|
|
123
|
+
}
|
|
124
|
+
if got := f.tasks.commentCount("verify", "summary"); got != 2 {
|
|
125
|
+
t.Fatalf("verify summaries = %d, want one per pass", got)
|
|
126
|
+
}
|
|
127
|
+
if got := f.tasks.commentCount("pr-review", "summary"); got != 2 {
|
|
128
|
+
t.Fatalf("pr-review summaries = %d, want one per pass", got)
|
|
129
|
+
}
|
|
130
|
+
for node, want := range map[string]int{"implement": 1, "verify": 2, "pr-review": 2, "parent": 0} {
|
|
131
|
+
if got := f.tasks.commentCount(node, "feedback"); got != want {
|
|
132
|
+
t.Fatalf("%s feedback comments = %d, want exactly %d", node, got, want)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if got := f.tasks.totalComments(); got != 11 {
|
|
136
|
+
t.Fatalf("loop comments = %d, want exactly 11 (one summary and selected feedback per pass)", got)
|
|
137
|
+
}
|
|
138
|
+
if got := f.tasks.totalMailboxCreates(); got != 3 {
|
|
139
|
+
t.Fatalf("mailboxes created = %d, want three reusable mailboxes", got)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
func TestScenarioAgentFailureRoutingAndInvalidNudge(t *testing.T) {
|
|
144
|
+
f := newScenarioFixture(t)
|
|
145
|
+
f.pollOnce()
|
|
146
|
+
f.waitNode("implement")
|
|
147
|
+
|
|
148
|
+
before := f.projection()
|
|
149
|
+
bad := scenarioReport(workflow.OutcomeFailure, "verify") // success-only target
|
|
150
|
+
ack, err := f.engine.SubmitReport(context.Background(), runsvc.ReportRequest{
|
|
151
|
+
RunID: f.runID, Node: before.CurrentNode, ReportID: "invalid-route", Report: bad,
|
|
152
|
+
})
|
|
153
|
+
if err == nil || ack.Accepted {
|
|
154
|
+
t.Fatalf("failure report naming success-only target accepted: ack=%+v err=%v", ack, err)
|
|
155
|
+
}
|
|
156
|
+
// Production plugin tests exercise invalid output -> session API nudge.
|
|
157
|
+
// Here the real server boundary must reject it without persistence.
|
|
158
|
+
after := f.projection()
|
|
159
|
+
if after.CurrentNodeVisitID != before.CurrentNodeVisitID || after.CurrentNode != "implement" {
|
|
160
|
+
t.Fatalf("invalid report changed projection: before=%+v after=%+v", before, after)
|
|
161
|
+
}
|
|
162
|
+
if got := f.tasks.totalComments(); got != 0 {
|
|
163
|
+
t.Fatalf("invalid report persisted graph effects: %d comments", got)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
f.submit(workflow.OutcomeFailure, "pr-review")
|
|
167
|
+
f.waitNode("pr-review")
|
|
168
|
+
if got := f.tasks.commentCount("pr-review", "feedback"); got != 1 {
|
|
169
|
+
t.Fatalf("failure feedback on configured failure target = %d, want 1", got)
|
|
170
|
+
}
|
|
171
|
+
if got := f.runner.launchCount("TEST-1:verify"); got != 0 {
|
|
172
|
+
t.Fatalf("failure followed success route and launched verify %d times", got)
|
|
173
|
+
}
|
|
174
|
+
f.submit(workflow.OutcomeSuccess, "end")
|
|
175
|
+
f.waitCompleted()
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
func TestScenarioCrashMidTransitionRollsForward(t *testing.T) {
|
|
179
|
+
f := newScenarioFixture(t)
|
|
180
|
+
f.pollOnce()
|
|
181
|
+
f.waitNode("implement")
|
|
182
|
+
f.tasks.setCompleteFailures(100)
|
|
183
|
+
f.submitWithoutWaiting(workflow.OutcomeSuccess, "verify")
|
|
184
|
+
f.waitEvent("complete-failed:TEST-1:implement")
|
|
185
|
+
if got := f.tasks.commentCount("implement", "summary"); got != 1 {
|
|
186
|
+
t.Fatalf("pre-crash summaries = %d, want 1", got)
|
|
187
|
+
}
|
|
188
|
+
if got := f.tasks.commentCount("verify", "feedback"); got != 1 {
|
|
189
|
+
t.Fatalf("pre-crash feedback = %d, want 1", got)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
f.restart()
|
|
193
|
+
f.tasks.setCompleteFailures(0)
|
|
194
|
+
f.waitNode("verify")
|
|
195
|
+
if got := f.tasks.commentCount("implement", "summary"); got != 1 {
|
|
196
|
+
t.Fatalf("summary duplicated after restart: %d", got)
|
|
197
|
+
}
|
|
198
|
+
if got := f.tasks.commentCount("verify", "feedback"); got != 1 {
|
|
199
|
+
t.Fatalf("feedback duplicated after restart: %d", got)
|
|
200
|
+
}
|
|
201
|
+
if got := f.tasks.completeCount("implement"); got != 1 {
|
|
202
|
+
t.Fatalf("successful implement completions = %d, want 1", got)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
f.submit(workflow.OutcomeSuccess, "pr-review")
|
|
206
|
+
f.waitNode("pr-review")
|
|
207
|
+
f.submit(workflow.OutcomeSuccess, "end")
|
|
208
|
+
f.waitCompleted()
|
|
209
|
+
assertNoDuplicateTransitionCalls(t, f.tasks.transitionCalls())
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
func TestScenarioLateRegisterAndLateSubmit(t *testing.T) {
|
|
213
|
+
log := newScenarioLog()
|
|
214
|
+
sys := newScenarioTaskSystem(log)
|
|
215
|
+
fr := newScenarioRunner(log)
|
|
216
|
+
fh := newScenarioHarness(log)
|
|
217
|
+
reg := repo.NewRegistry()
|
|
218
|
+
db := filepath.Join(t.TempDir(), "state.db")
|
|
219
|
+
engine, err := goworkflows.New(db, goworkflows.Dependencies{Repos: reg, Runner: fr, Harness: fh})
|
|
220
|
+
if err != nil {
|
|
221
|
+
t.Fatal(err)
|
|
222
|
+
}
|
|
223
|
+
if err := engine.Start(context.Background()); err != nil {
|
|
224
|
+
t.Fatal(err)
|
|
225
|
+
}
|
|
226
|
+
t.Cleanup(func() { shutdownScenarioEngine(engine) })
|
|
227
|
+
|
|
228
|
+
gate := &sync.Mutex{}
|
|
229
|
+
manager := &runsvc.RunManager{Executor: scenarioExecutor{inner: engine, log: log}, Runs: engine, Gate: gate}
|
|
230
|
+
pollers := repo.NewPollerGroup(10, handleBatch(manager))
|
|
231
|
+
pollers.Interval = 100 * time.Millisecond
|
|
232
|
+
ctx, cancel := context.WithCancel(context.Background())
|
|
233
|
+
done := make(chan struct{})
|
|
234
|
+
go func() { pollers.Run(ctx); close(done) }()
|
|
235
|
+
t.Cleanup(func() { cancel(); <-done })
|
|
236
|
+
|
|
237
|
+
repoLookup := scenarioRepoLookup{reg: reg}
|
|
238
|
+
store := &workflow.Store{Dir: filepath.Join(t.TempDir(), "workflows")}
|
|
239
|
+
wfService := workflow.NewService(store, engine, repoLookup)
|
|
240
|
+
wfService.Gate = gate
|
|
241
|
+
wfService.ValidateTaskConfig = workflowConfigValidator(reg)
|
|
242
|
+
wfService.Rebind = func() error { return reg.BindWorkflows(wfService.Registry().List()) }
|
|
243
|
+
configPath := filepath.Join(t.TempDir(), "config.yaml")
|
|
244
|
+
if err := config.SaveMachine(configPath, &config.Machine{
|
|
245
|
+
TaskPlugin: scenarioTaskPlugin, RunnerPlugin: "unused", HarnessPlugin: "unused",
|
|
246
|
+
Repos: map[string]config.Repo{},
|
|
247
|
+
}); err != nil {
|
|
248
|
+
t.Fatal(err)
|
|
249
|
+
}
|
|
250
|
+
repoService := repo.NewServiceWithRegistry(repo.ServiceConfig{
|
|
251
|
+
ConfigPath: configPath, TaskPlugin: scenarioTaskPlugin, Runner: fr,
|
|
252
|
+
Active: engine, Workflows: wfService.Registry(),
|
|
253
|
+
}, reg)
|
|
254
|
+
deps := &serveDeps{
|
|
255
|
+
repos: repoService,
|
|
256
|
+
onReposChanged: func() { pollers.ReplaceRepos(repoService.Registry().List()) },
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Normative inverse case: submission before registration is rejected
|
|
260
|
+
// completely; no definition, binding, claim, or run leaks through.
|
|
261
|
+
if _, err := wfService.Submit(context.Background(), scenarioWorkflowYAML); err == nil || !strings.Contains(err.Error(), "unregistered repo") {
|
|
262
|
+
t.Fatalf("submit before registration error = %v, want unregistered repo rejection", err)
|
|
263
|
+
}
|
|
264
|
+
if len(wfService.List()) != 0 || log.countPrefix("claim:") != 0 {
|
|
265
|
+
t.Fatal("rejected workflow submission left observable state")
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Runtime registration goes through the real repo service and the same
|
|
269
|
+
// serve callback that updates the already-running poller group.
|
|
270
|
+
setScenarioFactorySystem(sys)
|
|
271
|
+
if _, err := deps.RegisterRepo(context.Background(), repo.RegisterInput{
|
|
272
|
+
Name: scenarioRepo, Path: scenarioRepoPath,
|
|
273
|
+
}); err != nil {
|
|
274
|
+
t.Fatalf("register repo: %v", err)
|
|
275
|
+
}
|
|
276
|
+
waitScenario(t, 2*time.Second, func() bool { return log.countPrefix("poll") > 0 })
|
|
277
|
+
if log.countPrefix("claim:") != 0 {
|
|
278
|
+
t.Fatal("repo registration claimed before a workflow was submitted")
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Registration first, then submission: the real Service rebuilds the
|
|
282
|
+
// live repo binding, and the next poll claims without a server restart.
|
|
283
|
+
if _, err := wfService.Submit(context.Background(), scenarioWorkflowYAML); err != nil {
|
|
284
|
+
t.Fatalf("submit after registration: %v", err)
|
|
285
|
+
}
|
|
286
|
+
log.add("workflow:submitted")
|
|
287
|
+
wantID := identity.NewRunID(scenarioRepo, scenarioWorkflowName, scenarioTicket)
|
|
288
|
+
waitScenario(t, 5*time.Second, func() bool {
|
|
289
|
+
r, err := engine.GetRun(context.Background(), wantID)
|
|
290
|
+
return err == nil && r.CurrentNode == "implement"
|
|
291
|
+
})
|
|
292
|
+
assertBefore(t, log.all(), "workflow:submitted", "claim:TEST-1:scenarioFlow")
|
|
293
|
+
assertBefore(t, log.all(), "claim:TEST-1:scenarioFlow", "run-created:"+string(wantID))
|
|
294
|
+
|
|
295
|
+
finishScenarioRun(t, engine, wantID)
|
|
296
|
+
r, err := engine.GetRun(context.Background(), wantID)
|
|
297
|
+
if err != nil || r.State != runsvc.StateCompleted {
|
|
298
|
+
t.Fatalf("late-register run = %+v err=%v, want completed", r, err)
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const (
|
|
303
|
+
scenarioRepo = "fake-repo"
|
|
304
|
+
scenarioRepoPath = "/tmp/fake-repo"
|
|
305
|
+
scenarioWorkflowName = "scenarioFlow"
|
|
306
|
+
scenarioTicket = "TEST-1"
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
var scenarioWorkflowYAML = []byte(`name: scenarioFlow
|
|
310
|
+
repos: [fake-repo]
|
|
311
|
+
cleanupRunnerOnEnd: true
|
|
312
|
+
nodes:
|
|
313
|
+
start:
|
|
314
|
+
onSuccess: [{target: implement}]
|
|
315
|
+
implement:
|
|
316
|
+
type: agent
|
|
317
|
+
agent: implementer
|
|
318
|
+
description: Implement the requested change.
|
|
319
|
+
onSuccess: [{target: verify}]
|
|
320
|
+
onFailure: [{target: pr-review, when: implementation cannot proceed}]
|
|
321
|
+
verify:
|
|
322
|
+
type: agent
|
|
323
|
+
agent: verifier
|
|
324
|
+
description: Verify the implementation.
|
|
325
|
+
onSuccess: [{target: pr-review}]
|
|
326
|
+
onFailure: [{target: implement, when: verification fails}]
|
|
327
|
+
pr-review:
|
|
328
|
+
type: hitl
|
|
329
|
+
agent: reviewer
|
|
330
|
+
description: Review and approve the pull request.
|
|
331
|
+
onSuccess: [{target: end}]
|
|
332
|
+
onFailure: [{target: implement, when: changes are requested}]
|
|
333
|
+
end: {}
|
|
334
|
+
`)
|
|
335
|
+
|
|
336
|
+
func scenarioWorkflow() workflow.Workflow {
|
|
337
|
+
wf, err := workflow.Parse("", scenarioWorkflowYAML)
|
|
338
|
+
if err != nil {
|
|
339
|
+
panic(err)
|
|
340
|
+
}
|
|
341
|
+
if err := wf.Validate(); err != nil {
|
|
342
|
+
panic(err)
|
|
343
|
+
}
|
|
344
|
+
return *wf
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
type scenarioFixture struct {
|
|
348
|
+
t *testing.T
|
|
349
|
+
log *scenarioLog
|
|
350
|
+
tasks *scenarioTaskSystem
|
|
351
|
+
runner *scenarioRunner
|
|
352
|
+
harness *scenarioHarness
|
|
353
|
+
reg *repo.Registry
|
|
354
|
+
repo *repo.Repo
|
|
355
|
+
wf workflow.Workflow
|
|
356
|
+
db string
|
|
357
|
+
engine *goworkflows.Engine
|
|
358
|
+
manager *runsvc.RunManager
|
|
359
|
+
runID runsvc.ID
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
func newScenarioFixture(t *testing.T) *scenarioFixture {
|
|
363
|
+
t.Helper()
|
|
364
|
+
f := &scenarioFixture{t: t, log: newScenarioLog(), wf: scenarioWorkflow()}
|
|
365
|
+
f.tasks = newScenarioTaskSystem(f.log)
|
|
366
|
+
f.runner = newScenarioRunner(f.log)
|
|
367
|
+
f.harness = newScenarioHarness(f.log)
|
|
368
|
+
f.reg = repo.NewRegistry()
|
|
369
|
+
f.repo = &repo.Repo{Name: scenarioRepo, Path: scenarioRepoPath, TaskSystem: f.tasks}
|
|
370
|
+
f.reg.Replace(f.repo)
|
|
371
|
+
if err := f.reg.BindWorkflows([]*workflow.Workflow{&f.wf}); err != nil {
|
|
372
|
+
t.Fatal(err)
|
|
373
|
+
}
|
|
374
|
+
f.db = filepath.Join(t.TempDir(), "state.db")
|
|
375
|
+
f.engine = f.openEngine()
|
|
376
|
+
f.manager = &runsvc.RunManager{Executor: scenarioExecutor{inner: f.engine, log: f.log}, Runs: f.engine, Gate: &sync.Mutex{}}
|
|
377
|
+
f.runID = identity.NewRunID(scenarioRepo, scenarioWorkflowName, scenarioTicket)
|
|
378
|
+
t.Cleanup(func() { shutdownScenarioEngine(f.engine) })
|
|
379
|
+
return f
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
func (f *scenarioFixture) openEngine() *goworkflows.Engine {
|
|
383
|
+
f.t.Helper()
|
|
384
|
+
e, err := goworkflows.New(f.db, goworkflows.Dependencies{
|
|
385
|
+
Repos: f.reg, Runner: f.runner, Harness: f.harness,
|
|
386
|
+
Runtime: &runsvc.RuntimePolicy{},
|
|
387
|
+
})
|
|
388
|
+
if err != nil {
|
|
389
|
+
f.t.Fatal(err)
|
|
390
|
+
}
|
|
391
|
+
if err := e.Start(context.Background()); err != nil {
|
|
392
|
+
f.t.Fatal(err)
|
|
393
|
+
}
|
|
394
|
+
return e
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
func (f *scenarioFixture) pollOnce() {
|
|
398
|
+
f.t.Helper()
|
|
399
|
+
batch, err := f.tasks.Poll(context.Background())
|
|
400
|
+
if err != nil {
|
|
401
|
+
f.t.Fatal(err)
|
|
402
|
+
}
|
|
403
|
+
handleBatch(f.manager)(context.Background(), f.repo, batch)
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
func (f *scenarioFixture) projection() runsvc.Run {
|
|
407
|
+
f.t.Helper()
|
|
408
|
+
r, err := f.engine.GetRun(context.Background(), f.runID)
|
|
409
|
+
if err != nil {
|
|
410
|
+
f.t.Fatal(err)
|
|
411
|
+
}
|
|
412
|
+
return r
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
func (f *scenarioFixture) waitNode(node string) {
|
|
416
|
+
f.t.Helper()
|
|
417
|
+
waitScenario(f.t, 10*time.Second, func() bool {
|
|
418
|
+
r, err := f.engine.GetRun(context.Background(), f.runID)
|
|
419
|
+
return err == nil && r.CurrentNode == node && r.CurrentNodeVisitID != ""
|
|
420
|
+
})
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
func (f *scenarioFixture) waitNodeWithNewVisit(node string, old runsvc.NodeVisitID) {
|
|
424
|
+
f.t.Helper()
|
|
425
|
+
waitScenario(f.t, 10*time.Second, func() bool {
|
|
426
|
+
r, err := f.engine.GetRun(context.Background(), f.runID)
|
|
427
|
+
return err == nil && r.CurrentNode == node && r.CurrentNodeVisitID != "" && r.CurrentNodeVisitID != old
|
|
428
|
+
})
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
func (f *scenarioFixture) firstVisit(node string) runsvc.NodeVisitID {
|
|
432
|
+
f.t.Helper()
|
|
433
|
+
r := f.projection()
|
|
434
|
+
if r.CurrentNode != node {
|
|
435
|
+
f.t.Fatalf("current node = %q, want %q", r.CurrentNode, node)
|
|
436
|
+
}
|
|
437
|
+
return r.CurrentNodeVisitID
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
func (f *scenarioFixture) submit(status workflow.Outcome, next string) {
|
|
441
|
+
f.t.Helper()
|
|
442
|
+
current := f.projection().CurrentNode
|
|
443
|
+
f.submitWithoutWaiting(status, next)
|
|
444
|
+
if next == "end" {
|
|
445
|
+
return
|
|
446
|
+
}
|
|
447
|
+
waitScenario(f.t, 10*time.Second, func() bool {
|
|
448
|
+
r, err := f.engine.GetRun(context.Background(), f.runID)
|
|
449
|
+
return err == nil && r.CurrentNode == next && r.CurrentNode != current
|
|
450
|
+
})
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
func (f *scenarioFixture) submitWithoutWaiting(status workflow.Outcome, next string) {
|
|
454
|
+
f.t.Helper()
|
|
455
|
+
r := f.projection()
|
|
456
|
+
ack, err := f.engine.SubmitReport(context.Background(), runsvc.ReportRequest{
|
|
457
|
+
RunID: f.runID, Node: r.CurrentNode,
|
|
458
|
+
ReportID: string(r.CurrentNodeVisitID) + ":scenario", Report: scenarioReport(status, next),
|
|
459
|
+
})
|
|
460
|
+
if err != nil || !ack.Accepted {
|
|
461
|
+
f.t.Fatalf("submit %s -> %s: ack=%+v err=%v", r.CurrentNode, next, ack, err)
|
|
462
|
+
}
|
|
463
|
+
// A successful ack is the public guarantee that report+route persistence
|
|
464
|
+
// happened. Recording it here lets the cross-boundary ordering assertion
|
|
465
|
+
// compare that observable ack with subsequent adapter effects.
|
|
466
|
+
f.log.add("report-persisted:" + r.CurrentNode)
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
func (f *scenarioFixture) waitCompleted() {
|
|
470
|
+
f.t.Helper()
|
|
471
|
+
waitScenario(f.t, 15*time.Second, func() bool {
|
|
472
|
+
r, err := f.engine.GetRun(context.Background(), f.runID)
|
|
473
|
+
return err == nil && r.State == runsvc.StateCompleted
|
|
474
|
+
})
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
func (f *scenarioFixture) waitEvent(event string) {
|
|
478
|
+
f.t.Helper()
|
|
479
|
+
waitScenario(f.t, 10*time.Second, func() bool { return f.log.count(event) > 0 })
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
func (f *scenarioFixture) restart() {
|
|
483
|
+
f.t.Helper()
|
|
484
|
+
shutdownScenarioEngine(f.engine)
|
|
485
|
+
f.engine = f.openEngine()
|
|
486
|
+
f.manager.Executor = f.engine
|
|
487
|
+
f.manager.Runs = f.engine
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
func shutdownScenarioEngine(e *goworkflows.Engine) {
|
|
491
|
+
if e == nil {
|
|
492
|
+
return
|
|
493
|
+
}
|
|
494
|
+
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
495
|
+
defer cancel()
|
|
496
|
+
_ = e.Shutdown(ctx)
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
func finishScenarioRun(t *testing.T, engine *goworkflows.Engine, id runsvc.ID) {
|
|
500
|
+
t.Helper()
|
|
501
|
+
for _, next := range []string{"verify", "pr-review", "end"} {
|
|
502
|
+
var r runsvc.Run
|
|
503
|
+
waitScenario(t, 10*time.Second, func() bool {
|
|
504
|
+
var err error
|
|
505
|
+
r, err = engine.GetRun(context.Background(), id)
|
|
506
|
+
return err == nil && r.CurrentNodeVisitID != "" && r.State == runsvc.StateWaiting
|
|
507
|
+
})
|
|
508
|
+
ack, err := engine.SubmitReport(context.Background(), runsvc.ReportRequest{
|
|
509
|
+
RunID: id, Node: r.CurrentNode,
|
|
510
|
+
ReportID: string(r.CurrentNodeVisitID) + ":finish", Report: scenarioReport(workflow.OutcomeSuccess, next),
|
|
511
|
+
})
|
|
512
|
+
if err != nil || !ack.Accepted {
|
|
513
|
+
t.Fatalf("finish %s -> %s: ack=%+v err=%v", r.CurrentNode, next, ack, err)
|
|
514
|
+
}
|
|
515
|
+
if next != "end" {
|
|
516
|
+
want := next
|
|
517
|
+
waitScenario(t, 10*time.Second, func() bool {
|
|
518
|
+
n, err := engine.GetRun(context.Background(), id)
|
|
519
|
+
return err == nil && n.CurrentNode == want
|
|
520
|
+
})
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
waitScenario(t, 15*time.Second, func() bool {
|
|
524
|
+
r, err := engine.GetRun(context.Background(), id)
|
|
525
|
+
return err == nil && r.State == runsvc.StateCompleted
|
|
526
|
+
})
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
func scenarioReport(status workflow.Outcome, next string) workflow.Report {
|
|
530
|
+
none := "None"
|
|
531
|
+
r := workflow.Report{
|
|
532
|
+
Status: status, NextStep: next,
|
|
533
|
+
Summary: workflow.Summary{
|
|
534
|
+
Completed: "work completed", Commits: "abc123", NotCompleted: none, IssuesDiscovered: none,
|
|
535
|
+
Verification: "checks passed", Notes: none,
|
|
536
|
+
},
|
|
537
|
+
Feedback: workflow.Feedback{
|
|
538
|
+
ReasonForNextStep: "continue", RequiredActions: "process this mailbox",
|
|
539
|
+
RelevantContext: "scenario context", ExpectedResult: "successful next visit",
|
|
540
|
+
},
|
|
541
|
+
}
|
|
542
|
+
if next == "end" {
|
|
543
|
+
r.Feedback = workflow.Feedback{
|
|
544
|
+
ReasonForNextStep: none, RequiredActions: none, RelevantContext: none, ExpectedResult: none,
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
return r
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
type scenarioLog struct {
|
|
551
|
+
mu sync.Mutex
|
|
552
|
+
events []string
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
type scenarioExecutor struct {
|
|
556
|
+
inner runsvc.Executor
|
|
557
|
+
log *scenarioLog
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
func (e scenarioExecutor) EnsureRun(ctx context.Context, start runsvc.Start) (bool, error) {
|
|
561
|
+
created, err := e.inner.EnsureRun(ctx, start)
|
|
562
|
+
if err == nil && created {
|
|
563
|
+
e.log.add("run-created:" + string(start.ID))
|
|
564
|
+
}
|
|
565
|
+
return created, err
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
func (e scenarioExecutor) SubmitReport(ctx context.Context, report runsvc.ReportRequest) (runsvc.ReportAck, error) {
|
|
569
|
+
return e.inner.SubmitReport(ctx, report)
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
func (e scenarioExecutor) CancelRun(ctx context.Context, id runsvc.ID, reason string) error {
|
|
573
|
+
return e.inner.CancelRun(ctx, id, reason)
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
func newScenarioLog() *scenarioLog { return &scenarioLog{} }
|
|
577
|
+
|
|
578
|
+
func (l *scenarioLog) add(event string) {
|
|
579
|
+
l.mu.Lock()
|
|
580
|
+
l.events = append(l.events, event)
|
|
581
|
+
l.mu.Unlock()
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
func (l *scenarioLog) all() []string {
|
|
585
|
+
l.mu.Lock()
|
|
586
|
+
defer l.mu.Unlock()
|
|
587
|
+
return append([]string(nil), l.events...)
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
func (l *scenarioLog) count(event string) int {
|
|
591
|
+
n := 0
|
|
592
|
+
for _, got := range l.all() {
|
|
593
|
+
if got == event {
|
|
594
|
+
n++
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
return n
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
func (l *scenarioLog) countPrefix(prefix string) int {
|
|
601
|
+
n := 0
|
|
602
|
+
for _, got := range l.all() {
|
|
603
|
+
if strings.HasPrefix(got, prefix) {
|
|
604
|
+
n++
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
return n
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
type scenarioComment struct {
|
|
611
|
+
node string
|
|
612
|
+
kind string
|
|
613
|
+
body string
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
type scenarioTaskSystem struct {
|
|
617
|
+
log *scenarioLog
|
|
618
|
+
mu sync.Mutex
|
|
619
|
+
|
|
620
|
+
claimed bool
|
|
621
|
+
mailboxes map[string]task.Mailbox
|
|
622
|
+
specs map[string]task.MailboxSpec
|
|
623
|
+
mailboxStatus map[string]string
|
|
624
|
+
parentStatus string
|
|
625
|
+
comments map[string]scenarioComment
|
|
626
|
+
transitions []string
|
|
627
|
+
creates map[string]int
|
|
628
|
+
completions map[string]int
|
|
629
|
+
completeFailures int
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
func newScenarioTaskSystem(log *scenarioLog) *scenarioTaskSystem {
|
|
633
|
+
return &scenarioTaskSystem{
|
|
634
|
+
log: log, mailboxes: map[string]task.Mailbox{}, specs: map[string]task.MailboxSpec{},
|
|
635
|
+
mailboxStatus: map[string]string{}, comments: map[string]scenarioComment{},
|
|
636
|
+
creates: map[string]int{}, completions: map[string]int{},
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
func (s *scenarioTaskSystem) Poll(context.Context) ([]task.Ticket, error) {
|
|
641
|
+
s.log.add("poll")
|
|
642
|
+
s.mu.Lock()
|
|
643
|
+
defer s.mu.Unlock()
|
|
644
|
+
if s.claimed {
|
|
645
|
+
return nil, nil
|
|
646
|
+
}
|
|
647
|
+
return []task.Ticket{{ID: "ticket-1", Key: scenarioTicket, Title: "Scenario ticket"}}, nil
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
func (s *scenarioTaskSystem) CompileFilter(config.RawValues) (func(task.Ticket) bool, error) {
|
|
651
|
+
return func(t task.Ticket) bool { return t.Key == scenarioTicket }, nil
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
func (s *scenarioTaskSystem) Claim(_ context.Context, ref task.TicketRef, workflowName string) error {
|
|
655
|
+
s.log.add("claim:" + ref.Key + ":" + workflowName)
|
|
656
|
+
s.mu.Lock()
|
|
657
|
+
s.claimed = true
|
|
658
|
+
s.mu.Unlock()
|
|
659
|
+
return nil
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
func (s *scenarioTaskSystem) ValidateConfig(context.Context, config.RawValues, map[string]config.RawValues) error {
|
|
663
|
+
return nil
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
func (s *scenarioTaskSystem) EnsureMailboxes(_ context.Context, parent task.TicketRef, _ string, specs []task.MailboxSpec) (map[string]task.Mailbox, error) {
|
|
667
|
+
s.log.add("mailboxes:ensure")
|
|
668
|
+
s.mu.Lock()
|
|
669
|
+
defer s.mu.Unlock()
|
|
670
|
+
out := map[string]task.Mailbox{}
|
|
671
|
+
for _, spec := range specs {
|
|
672
|
+
s.specs[spec.Node] = spec
|
|
673
|
+
mb, ok := s.mailboxes[spec.Node]
|
|
674
|
+
if !ok {
|
|
675
|
+
mb = task.Mailbox{ID: "mb-" + spec.Node, Key: spec.Title, Node: spec.Node}
|
|
676
|
+
s.mailboxes[spec.Node] = mb
|
|
677
|
+
s.mailboxStatus[spec.Node] = "To Do"
|
|
678
|
+
s.creates[spec.Node]++
|
|
679
|
+
s.log.add("mailbox-created:" + spec.Title)
|
|
680
|
+
}
|
|
681
|
+
out[spec.Node] = mb
|
|
682
|
+
}
|
|
683
|
+
return out, nil
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
func (s *scenarioTaskSystem) StartDefaults() config.RawValues {
|
|
687
|
+
return config.RawValues{"transitionTo": map[string]any{"parentStatus": "In Progress"}}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
func (s *scenarioTaskSystem) WorkDefaults() config.RawValues {
|
|
691
|
+
return config.RawValues{"transitionTo": map[string]any{"taskStatus": "In Progress"}}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
func (s *scenarioTaskSystem) EndDefaults() config.RawValues {
|
|
695
|
+
return config.RawValues{"transitionTo": map[string]any{"parentStatus": "Done"}}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
func (s *scenarioTaskSystem) ApplyTaskConfig(_ context.Context, target task.Target, cfg config.RawValues) error {
|
|
699
|
+
transition, _ := cfg["transitionTo"].(map[string]any)
|
|
700
|
+
s.mu.Lock()
|
|
701
|
+
defer s.mu.Unlock()
|
|
702
|
+
if target.Mailbox == nil {
|
|
703
|
+
status, _ := transition["parentStatus"].(string)
|
|
704
|
+
s.parentStatus = status
|
|
705
|
+
call := "parent:" + status
|
|
706
|
+
s.transitions = append(s.transitions, call)
|
|
707
|
+
s.log.add("transition:" + call)
|
|
708
|
+
return nil
|
|
709
|
+
}
|
|
710
|
+
status, _ := transition["taskStatus"].(string)
|
|
711
|
+
s.mailboxStatus[target.Mailbox.Node] = status
|
|
712
|
+
call := target.Mailbox.Node + ":" + status
|
|
713
|
+
s.transitions = append(s.transitions, call)
|
|
714
|
+
s.log.add("transition:" + call)
|
|
715
|
+
return nil
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
func (s *scenarioTaskSystem) CompleteMailbox(_ context.Context, mailbox task.Mailbox) error {
|
|
719
|
+
s.mu.Lock()
|
|
720
|
+
if s.completeFailures > 0 {
|
|
721
|
+
s.completeFailures--
|
|
722
|
+
s.mu.Unlock()
|
|
723
|
+
s.log.add("complete-failed:" + mailbox.Key)
|
|
724
|
+
return errors.New("temporary completion failure")
|
|
725
|
+
}
|
|
726
|
+
s.mailboxStatus[mailbox.Node] = "Done"
|
|
727
|
+
s.completions[mailbox.Node]++
|
|
728
|
+
s.mu.Unlock()
|
|
729
|
+
s.log.add("complete:" + mailbox.Key)
|
|
730
|
+
return nil
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
func (s *scenarioTaskSystem) HasComment(_ context.Context, _ task.Target, marker string) (bool, error) {
|
|
734
|
+
s.mu.Lock()
|
|
735
|
+
defer s.mu.Unlock()
|
|
736
|
+
_, ok := s.comments[marker]
|
|
737
|
+
return ok, nil
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
func (s *scenarioTaskSystem) Comment(_ context.Context, target task.Target, body, marker string) error {
|
|
741
|
+
s.mu.Lock()
|
|
742
|
+
if _, exists := s.comments[marker]; exists {
|
|
743
|
+
s.mu.Unlock()
|
|
744
|
+
s.log.add("comment-existing:" + marker)
|
|
745
|
+
return nil
|
|
746
|
+
}
|
|
747
|
+
node := "parent"
|
|
748
|
+
if target.Mailbox != nil {
|
|
749
|
+
node = target.Mailbox.Node
|
|
750
|
+
}
|
|
751
|
+
kind := marker[strings.LastIndex(marker, ":")+1:]
|
|
752
|
+
s.comments[marker] = scenarioComment{node: node, kind: kind, body: body}
|
|
753
|
+
s.mu.Unlock()
|
|
754
|
+
s.log.add("comment:" + scenarioTicket + ":" + node + ":" + kind)
|
|
755
|
+
return nil
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
func (s *scenarioTaskSystem) ResetForRecovery(context.Context, task.TicketRef, []task.Mailbox, config.RawValues) error {
|
|
759
|
+
return nil
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
func (s *scenarioTaskSystem) setCompleteFailures(n int) {
|
|
763
|
+
s.mu.Lock()
|
|
764
|
+
s.completeFailures = n
|
|
765
|
+
s.mu.Unlock()
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
func (s *scenarioTaskSystem) mailboxCreateCount(node string) int {
|
|
769
|
+
s.mu.Lock()
|
|
770
|
+
defer s.mu.Unlock()
|
|
771
|
+
return s.creates[node]
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
func (s *scenarioTaskSystem) totalMailboxCreates() int {
|
|
775
|
+
s.mu.Lock()
|
|
776
|
+
defer s.mu.Unlock()
|
|
777
|
+
n := 0
|
|
778
|
+
for _, count := range s.creates {
|
|
779
|
+
n += count
|
|
780
|
+
}
|
|
781
|
+
return n
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
func (s *scenarioTaskSystem) commentCount(node, kind string) int {
|
|
785
|
+
s.mu.Lock()
|
|
786
|
+
defer s.mu.Unlock()
|
|
787
|
+
n := 0
|
|
788
|
+
for _, comment := range s.comments {
|
|
789
|
+
if comment.node == node && comment.kind == kind {
|
|
790
|
+
n++
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
return n
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
func (s *scenarioTaskSystem) totalComments() int {
|
|
797
|
+
s.mu.Lock()
|
|
798
|
+
defer s.mu.Unlock()
|
|
799
|
+
return len(s.comments)
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
func (s *scenarioTaskSystem) completeCount(node string) int {
|
|
803
|
+
s.mu.Lock()
|
|
804
|
+
defer s.mu.Unlock()
|
|
805
|
+
return s.completions[node]
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
func (s *scenarioTaskSystem) transitionCalls() []string {
|
|
809
|
+
s.mu.Lock()
|
|
810
|
+
defer s.mu.Unlock()
|
|
811
|
+
return append([]string(nil), s.transitions...)
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
type scenarioTerminal struct {
|
|
815
|
+
terminal runner.Terminal
|
|
816
|
+
live bool
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
type scenarioRunner struct {
|
|
820
|
+
log *scenarioLog
|
|
821
|
+
mu sync.Mutex
|
|
822
|
+
|
|
823
|
+
environments map[string]runner.Environment
|
|
824
|
+
terminals map[string]*scenarioTerminal
|
|
825
|
+
commands map[string][]runner.Command
|
|
826
|
+
launches map[string]int
|
|
827
|
+
cleanups int
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
func newScenarioRunner(log *scenarioLog) *scenarioRunner {
|
|
831
|
+
return &scenarioRunner{
|
|
832
|
+
log: log, environments: map[string]runner.Environment{}, terminals: map[string]*scenarioTerminal{},
|
|
833
|
+
commands: map[string][]runner.Command{}, launches: map[string]int{},
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
func (r *scenarioRunner) DiscoverRepos(context.Context) ([]runner.RepoCandidate, error) {
|
|
838
|
+
return []runner.RepoCandidate{{Name: scenarioRepo, Path: scenarioRepoPath}}, nil
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
func (r *scenarioRunner) ValidateRepo(context.Context, string, string) error { return nil }
|
|
842
|
+
|
|
843
|
+
func (r *scenarioRunner) EnsureEnvironment(_ context.Context, spec runner.RunSpec) (runner.Environment, error) {
|
|
844
|
+
r.mu.Lock()
|
|
845
|
+
defer r.mu.Unlock()
|
|
846
|
+
if env, ok := r.environments[string(spec.RunID)]; ok {
|
|
847
|
+
return env, nil
|
|
848
|
+
}
|
|
849
|
+
env := runner.Environment{ID: "env-" + string(spec.RunID), Path: scenarioRepoPath}
|
|
850
|
+
r.environments[string(spec.RunID)] = env
|
|
851
|
+
r.log.add("environment:" + string(spec.RunID))
|
|
852
|
+
return env, nil
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
func (r *scenarioRunner) FindTerminal(_ context.Context, _ runner.Environment, title string) (runner.Terminal, bool, error) {
|
|
856
|
+
r.mu.Lock()
|
|
857
|
+
defer r.mu.Unlock()
|
|
858
|
+
t, ok := r.terminals[title]
|
|
859
|
+
if !ok || !t.live {
|
|
860
|
+
return runner.Terminal{}, false, nil
|
|
861
|
+
}
|
|
862
|
+
return t.terminal, true, nil
|
|
863
|
+
}
|
|
864
|
+
func (r *scenarioRunner) InspectTerminal(_ context.Context, terminal runner.Terminal) (runner.Terminal, bool, error) {
|
|
865
|
+
r.mu.Lock()
|
|
866
|
+
defer r.mu.Unlock()
|
|
867
|
+
for _, current := range r.terminals {
|
|
868
|
+
if current.terminal.ID == terminal.ID && current.live {
|
|
869
|
+
return current.terminal, true, nil
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
return runner.Terminal{}, false, nil
|
|
873
|
+
}
|
|
874
|
+
func (r *scenarioRunner) SendTerminal(context.Context, runner.Terminal, string) error { return nil }
|
|
875
|
+
func (r *scenarioRunner) CreateTerminal(ctx context.Context, env runner.Environment, title string, command runner.Command) (runner.Terminal, error) {
|
|
876
|
+
return r.EnsureTerminal(ctx, env, title, command)
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
func (r *scenarioRunner) CloseTerminal(_ context.Context, terminal runner.Terminal) error {
|
|
880
|
+
r.mu.Lock()
|
|
881
|
+
defer r.mu.Unlock()
|
|
882
|
+
if t, ok := r.terminals[terminal.Title]; ok {
|
|
883
|
+
t.live = false
|
|
884
|
+
}
|
|
885
|
+
r.log.add("terminal-closed:" + terminal.Title)
|
|
886
|
+
return nil
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
func (r *scenarioRunner) EnsureTerminal(_ context.Context, _ runner.Environment, title string, command runner.Command) (runner.Terminal, error) {
|
|
890
|
+
r.mu.Lock()
|
|
891
|
+
defer r.mu.Unlock()
|
|
892
|
+
if existing, ok := r.terminals[title]; ok && existing.live {
|
|
893
|
+
return existing.terminal, nil
|
|
894
|
+
}
|
|
895
|
+
terminal := runner.Terminal{ID: "terminal-" + title, Title: title}
|
|
896
|
+
r.terminals[title] = &scenarioTerminal{terminal: terminal, live: true}
|
|
897
|
+
r.commands[title] = append(r.commands[title], command)
|
|
898
|
+
r.launches[title]++
|
|
899
|
+
r.log.add("terminal-created:" + title)
|
|
900
|
+
return terminal, nil
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
func (r *scenarioRunner) CloseTerminals(context.Context, runner.RunSpec) error {
|
|
904
|
+
r.mu.Lock()
|
|
905
|
+
defer r.mu.Unlock()
|
|
906
|
+
for _, terminal := range r.terminals {
|
|
907
|
+
terminal.live = false
|
|
908
|
+
}
|
|
909
|
+
return nil
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
func (r *scenarioRunner) CleanupRun(context.Context, runner.RunSpec) error {
|
|
913
|
+
r.mu.Lock()
|
|
914
|
+
r.cleanups++
|
|
915
|
+
r.terminals = map[string]*scenarioTerminal{}
|
|
916
|
+
r.mu.Unlock()
|
|
917
|
+
r.log.add("runner-cleanup")
|
|
918
|
+
return nil
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
func (r *scenarioRunner) launchCount(title string) int {
|
|
922
|
+
r.mu.Lock()
|
|
923
|
+
defer r.mu.Unlock()
|
|
924
|
+
return r.launches[title]
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
func (r *scenarioRunner) command(title string) runner.Command {
|
|
928
|
+
r.mu.Lock()
|
|
929
|
+
defer r.mu.Unlock()
|
|
930
|
+
commands := r.commands[title]
|
|
931
|
+
if len(commands) == 0 {
|
|
932
|
+
return runner.Command{}
|
|
933
|
+
}
|
|
934
|
+
return commands[len(commands)-1]
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
func (r *scenarioRunner) cleanupCount() int {
|
|
938
|
+
r.mu.Lock()
|
|
939
|
+
defer r.mu.Unlock()
|
|
940
|
+
return r.cleanups
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
type scenarioHarness struct {
|
|
944
|
+
log *scenarioLog
|
|
945
|
+
mu sync.Mutex
|
|
946
|
+
|
|
947
|
+
sessions map[string]harness.Session
|
|
948
|
+
launches map[string][]harness.LaunchSpec
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
func newScenarioHarness(log *scenarioLog) *scenarioHarness {
|
|
952
|
+
return &scenarioHarness{log: log, sessions: map[string]harness.Session{}, launches: map[string][]harness.LaunchSpec{}}
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
func (h *scenarioHarness) ValidateAgent(context.Context, string, string) error { return nil }
|
|
956
|
+
|
|
957
|
+
func (h *scenarioHarness) FindSession(_ context.Context, _ string, title string) (harness.Session, bool, error) {
|
|
958
|
+
h.mu.Lock()
|
|
959
|
+
defer h.mu.Unlock()
|
|
960
|
+
session, ok := h.sessions[title]
|
|
961
|
+
return session, ok, nil
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
func (h *scenarioHarness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error) {
|
|
965
|
+
h.mu.Lock()
|
|
966
|
+
h.launches[spec.Node] = append(h.launches[spec.Node], spec)
|
|
967
|
+
h.sessions[spec.Title] = harness.Session{ID: "session-" + spec.Title, Title: spec.Title}
|
|
968
|
+
h.mu.Unlock()
|
|
969
|
+
h.log.add("harness-launched:" + spec.Title)
|
|
970
|
+
return runner.Command{
|
|
971
|
+
Executable: "fake-harness",
|
|
972
|
+
Env: map[string]string{
|
|
973
|
+
"RELAY_FLOW_RUN_ID": string(spec.RunID),
|
|
974
|
+
"RELAY_FLOW_WORKFLOW": spec.Workflow,
|
|
975
|
+
"RELAY_FLOW_REPO": spec.RepoName,
|
|
976
|
+
"RELAY_FLOW_TICKET": spec.Ticket,
|
|
977
|
+
"RELAY_FLOW_NODE": spec.Node,
|
|
978
|
+
"RELAY_FLOW_NODE_TYPE": string(spec.NodeType),
|
|
979
|
+
"RELAY_FLOW_NUDGE_PROMPT": spec.NudgePrompt,
|
|
980
|
+
"RELAY_FLOW_NEXT_STEPS_JSON": routesJSON(spec.NextSteps),
|
|
981
|
+
},
|
|
982
|
+
}, nil
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
func (h *scenarioHarness) launch(node string) harness.LaunchSpec {
|
|
986
|
+
h.mu.Lock()
|
|
987
|
+
defer h.mu.Unlock()
|
|
988
|
+
launches := h.launches[node]
|
|
989
|
+
if len(launches) == 0 {
|
|
990
|
+
return harness.LaunchSpec{}
|
|
991
|
+
}
|
|
992
|
+
return launches[len(launches)-1]
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
func routesJSON(routes []workflow.Route) string {
|
|
996
|
+
parts := make([]string, 0, len(routes))
|
|
997
|
+
for _, route := range routes {
|
|
998
|
+
parts = append(parts, route.Target)
|
|
999
|
+
}
|
|
1000
|
+
sort.Strings(parts)
|
|
1001
|
+
return "[\"" + strings.Join(parts, "\",\"") + "\"]"
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
type scenarioRepoLookup struct{ reg *repo.Registry }
|
|
1005
|
+
|
|
1006
|
+
func (r scenarioRepoLookup) Exists(name string) bool {
|
|
1007
|
+
_, ok := r.reg.Get(name)
|
|
1008
|
+
return ok
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
func waitScenario(t *testing.T, timeout time.Duration, condition func() bool) {
|
|
1012
|
+
t.Helper()
|
|
1013
|
+
deadline := time.Now().Add(timeout)
|
|
1014
|
+
for time.Now().Before(deadline) {
|
|
1015
|
+
if condition() {
|
|
1016
|
+
return
|
|
1017
|
+
}
|
|
1018
|
+
time.Sleep(10 * time.Millisecond)
|
|
1019
|
+
}
|
|
1020
|
+
t.Fatal("condition not met within " + timeout.String())
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
func eventIndex(events []string, want string) int {
|
|
1024
|
+
for i, event := range events {
|
|
1025
|
+
if event == want {
|
|
1026
|
+
return i
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
return -1
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
func assertBefore(t *testing.T, events []string, first, second string) {
|
|
1033
|
+
t.Helper()
|
|
1034
|
+
a, b := eventIndex(events, first), eventIndex(events, second)
|
|
1035
|
+
if a < 0 || b < 0 || a >= b {
|
|
1036
|
+
t.Fatalf("want %q before %q; events=%v", first, second, events)
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
func assertTransitionOrder(t *testing.T, events []string, current, next string) {
|
|
1041
|
+
t.Helper()
|
|
1042
|
+
order := []string{
|
|
1043
|
+
"report-persisted:" + current,
|
|
1044
|
+
"comment:TEST-1:" + current + ":summary",
|
|
1045
|
+
"comment:TEST-1:" + next + ":feedback",
|
|
1046
|
+
"complete:TEST-1:" + current,
|
|
1047
|
+
"transition:" + next + ":In Progress",
|
|
1048
|
+
"terminal-created:TEST-1:" + next,
|
|
1049
|
+
}
|
|
1050
|
+
for i := 0; i+1 < len(order); i++ {
|
|
1051
|
+
assertBefore(t, events, order[i], order[i+1])
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
func assertMailboxDefinitions(t *testing.T, tasks *scenarioTaskSystem) {
|
|
1056
|
+
t.Helper()
|
|
1057
|
+
tasks.mu.Lock()
|
|
1058
|
+
defer tasks.mu.Unlock()
|
|
1059
|
+
for node, description := range map[string]string{
|
|
1060
|
+
"implement": "Implement the requested change.",
|
|
1061
|
+
"verify": "Verify the implementation.",
|
|
1062
|
+
"pr-review": "Review and approve the pull request.",
|
|
1063
|
+
} {
|
|
1064
|
+
spec, ok := tasks.specs[node]
|
|
1065
|
+
if !ok {
|
|
1066
|
+
t.Fatalf("mailbox spec %q missing", node)
|
|
1067
|
+
}
|
|
1068
|
+
if spec.Title != "TEST-1:"+node || !strings.Contains(spec.Description, description) {
|
|
1069
|
+
t.Fatalf("mailbox %q = %+v, want stable title and node description", node, spec)
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
if len(tasks.specs) != 3 {
|
|
1073
|
+
t.Fatalf("mailbox specs = %d, want 3 (no start/end mailbox)", len(tasks.specs))
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
func assertLaunch(t *testing.T, f *scenarioFixture, node string, nodeType workflow.NodeType) {
|
|
1078
|
+
t.Helper()
|
|
1079
|
+
title := "TEST-1:" + node
|
|
1080
|
+
launch := f.harness.launch(node)
|
|
1081
|
+
if launch.Title != title || launch.NodeType != nodeType {
|
|
1082
|
+
t.Fatalf("launch %q = %+v", node, launch)
|
|
1083
|
+
}
|
|
1084
|
+
cmd := f.runner.command(title)
|
|
1085
|
+
for _, key := range []string{
|
|
1086
|
+
"RELAY_FLOW_RUN_ID", "RELAY_FLOW_WORKFLOW", "RELAY_FLOW_REPO",
|
|
1087
|
+
"RELAY_FLOW_TICKET", "RELAY_FLOW_NODE", "RELAY_FLOW_NODE_TYPE", "RELAY_FLOW_NEXT_STEPS_JSON",
|
|
1088
|
+
} {
|
|
1089
|
+
if cmd.Env[key] == "" {
|
|
1090
|
+
t.Errorf("%s missing from %s launch env", key, node)
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
if _, ok := cmd.Env["RELAY_FLOW_NUDGE_PROMPT"]; !ok {
|
|
1094
|
+
t.Errorf("RELAY_FLOW_NUDGE_PROMPT missing from %s launch env", node)
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
func assertExactHappyEffects(t *testing.T, f *scenarioFixture) {
|
|
1099
|
+
t.Helper()
|
|
1100
|
+
if got := f.tasks.totalMailboxCreates(); got != 3 {
|
|
1101
|
+
t.Fatalf("mailbox creates = %d, want 3", got)
|
|
1102
|
+
}
|
|
1103
|
+
if got := f.tasks.totalComments(); got != 5 {
|
|
1104
|
+
t.Fatalf("comments = %d, want 5", got)
|
|
1105
|
+
}
|
|
1106
|
+
for _, node := range []string{"implement", "verify", "pr-review"} {
|
|
1107
|
+
if got := f.tasks.commentCount(node, "summary"); got != 1 {
|
|
1108
|
+
t.Errorf("%s summaries = %d, want 1", node, got)
|
|
1109
|
+
}
|
|
1110
|
+
if got := f.tasks.completeCount(node); got != 1 {
|
|
1111
|
+
t.Errorf("%s completions = %d, want 1", node, got)
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
if got := f.tasks.commentCount("verify", "feedback"); got != 1 {
|
|
1115
|
+
t.Errorf("verify feedback = %d, want 1", got)
|
|
1116
|
+
}
|
|
1117
|
+
if got := f.tasks.commentCount("pr-review", "feedback"); got != 1 {
|
|
1118
|
+
t.Errorf("pr-review feedback = %d, want 1", got)
|
|
1119
|
+
}
|
|
1120
|
+
if got := f.tasks.commentCount("parent", "feedback"); got != 0 {
|
|
1121
|
+
t.Errorf("feedback written for end = %d", got)
|
|
1122
|
+
}
|
|
1123
|
+
assertNoDuplicateTransitionCalls(t, f.tasks.transitionCalls())
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
func assertNoDuplicateTransitionCalls(t *testing.T, calls []string) {
|
|
1127
|
+
t.Helper()
|
|
1128
|
+
want := []string{
|
|
1129
|
+
"parent:In Progress", "implement:In Progress", "verify:In Progress",
|
|
1130
|
+
"pr-review:In Progress", "parent:Done",
|
|
1131
|
+
}
|
|
1132
|
+
if strings.Join(calls, "|") != strings.Join(want, "|") {
|
|
1133
|
+
t.Fatalf("transition calls = %v, want exactly %v", calls, want)
|
|
1134
|
+
}
|
|
1135
|
+
}
|