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,517 @@
|
|
|
1
|
+
package goworkflows_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"fmt"
|
|
6
|
+
"sync"
|
|
7
|
+
"sync/atomic"
|
|
8
|
+
"time"
|
|
9
|
+
|
|
10
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
11
|
+
"github.com/rajpopat27/relay-flow/internal/harness"
|
|
12
|
+
"github.com/rajpopat27/relay-flow/internal/retry"
|
|
13
|
+
"github.com/rajpopat27/relay-flow/internal/run"
|
|
14
|
+
"github.com/rajpopat27/relay-flow/internal/runner"
|
|
15
|
+
"github.com/rajpopat27/relay-flow/internal/task"
|
|
16
|
+
"github.com/rajpopat27/relay-flow/internal/workflow"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
// Shared test fakes for the engine-level section-3 tests. Every external
|
|
20
|
+
// effect is appended to one ordered event log so tests can assert
|
|
21
|
+
// cross-primitive ordering (3.16), claim/cancellation sequencing (3.19),
|
|
22
|
+
// reconcile signals (3.21), and no-rollback (3.17).
|
|
23
|
+
|
|
24
|
+
type eventLog struct {
|
|
25
|
+
mu sync.Mutex
|
|
26
|
+
events []string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
var testReportID atomic.Uint64
|
|
30
|
+
|
|
31
|
+
func reportRequest(id run.ID, node string, report workflow.Report) run.ReportRequest {
|
|
32
|
+
return run.ReportRequest{
|
|
33
|
+
RunID: id, Node: node,
|
|
34
|
+
ReportID: fmt.Sprintf("test:%d", testReportID.Add(1)),
|
|
35
|
+
Report: report,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
func newEventLog() *eventLog { return &eventLog{} }
|
|
40
|
+
|
|
41
|
+
func (l *eventLog) add(e string) {
|
|
42
|
+
l.mu.Lock()
|
|
43
|
+
l.events = append(l.events, e)
|
|
44
|
+
l.mu.Unlock()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
func (l *eventLog) all() []string {
|
|
48
|
+
l.mu.Lock()
|
|
49
|
+
defer l.mu.Unlock()
|
|
50
|
+
return append([]string{}, l.events...)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
func (l *eventLog) count(prefix string) int {
|
|
54
|
+
l.mu.Lock()
|
|
55
|
+
defer l.mu.Unlock()
|
|
56
|
+
n := 0
|
|
57
|
+
for _, e := range l.events {
|
|
58
|
+
if len(e) >= len(prefix) && e[:len(prefix)] == prefix {
|
|
59
|
+
n++
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return n
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// --- task system ---
|
|
66
|
+
|
|
67
|
+
type fakeTaskSystem struct {
|
|
68
|
+
log *eventLog
|
|
69
|
+
|
|
70
|
+
mu sync.Mutex
|
|
71
|
+
mailboxes map[string]task.Mailbox
|
|
72
|
+
mailboxStatus map[string]string // mailbox key -> task status
|
|
73
|
+
labels map[string][]string // mailbox key -> labels
|
|
74
|
+
specs []task.MailboxSpec
|
|
75
|
+
comments []recordedComment
|
|
76
|
+
resets []string
|
|
77
|
+
|
|
78
|
+
// Failure/crash/slow injection — the fake IS the documented injection
|
|
79
|
+
// seam (allowed seam a). These make the fake adapter fail/stall so the
|
|
80
|
+
// engine's durable retry/crash behavior is observable through outcomes.
|
|
81
|
+
completeFail int // next N CompleteMailbox calls return transient error
|
|
82
|
+
completeConflict bool // CompleteMailbox returns retry.ConflictError
|
|
83
|
+
completeSlow time.Duration // CompleteMailbox sleeps (a running activity)
|
|
84
|
+
failComments bool // Comment returns transient error
|
|
85
|
+
|
|
86
|
+
// Recovery fixtures.
|
|
87
|
+
parentsToRecover []task.Ticket
|
|
88
|
+
canceledParents map[string]bool
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
type recordedComment struct {
|
|
92
|
+
Key string
|
|
93
|
+
Body string
|
|
94
|
+
Marker string
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
func newFakeTaskSystem(log *eventLog) *fakeTaskSystem {
|
|
98
|
+
return &fakeTaskSystem{
|
|
99
|
+
log: log,
|
|
100
|
+
mailboxes: map[string]task.Mailbox{},
|
|
101
|
+
mailboxStatus: map[string]string{},
|
|
102
|
+
labels: map[string][]string{},
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
func (s *fakeTaskSystem) Poll(context.Context) ([]task.Ticket, error) {
|
|
107
|
+
s.mu.Lock()
|
|
108
|
+
defer s.mu.Unlock()
|
|
109
|
+
return append([]task.Ticket{}, s.parentsToRecover...), nil
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
func (s *fakeTaskSystem) CompileFilter(config.RawValues) (func(task.Ticket) bool, error) {
|
|
113
|
+
return func(task.Ticket) bool { return true }, nil
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
func (s *fakeTaskSystem) Claim(_ context.Context, ref task.TicketRef, wf string) error {
|
|
117
|
+
s.log.add("claim:" + ref.Key + ":" + wf)
|
|
118
|
+
return nil
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
func (s *fakeTaskSystem) ValidateConfig(context.Context, config.RawValues, map[string]config.RawValues) error {
|
|
122
|
+
return nil
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
func (s *fakeTaskSystem) EnsureMailboxes(_ context.Context, parent task.TicketRef, wf string, specs []task.MailboxSpec) (map[string]task.Mailbox, error) {
|
|
126
|
+
s.log.add("ensureMailboxes:" + parent.Key)
|
|
127
|
+
s.mu.Lock()
|
|
128
|
+
defer s.mu.Unlock()
|
|
129
|
+
s.specs = append(s.specs, specs...)
|
|
130
|
+
out := map[string]task.Mailbox{}
|
|
131
|
+
for _, sp := range specs {
|
|
132
|
+
// Per-parent mailbox identity: parent key + node.
|
|
133
|
+
key := parent.Key + "/" + sp.Node
|
|
134
|
+
mb, ok := s.mailboxes[key]
|
|
135
|
+
if !ok {
|
|
136
|
+
mb = task.Mailbox{ID: "mb-" + parent.Key + "-" + sp.Node, Key: parent.Key + "-" + sp.Node, Node: sp.Node}
|
|
137
|
+
s.mailboxes[key] = mb
|
|
138
|
+
s.mailboxStatus[mb.Key] = "To Do"
|
|
139
|
+
s.labels[mb.Key] = []string{"wf:" + wf}
|
|
140
|
+
s.log.add("createMailbox:" + parent.Key + ":" + sp.Node)
|
|
141
|
+
} else {
|
|
142
|
+
s.log.add("foundMailbox:" + parent.Key + ":" + sp.Node)
|
|
143
|
+
}
|
|
144
|
+
out[sp.Node] = mb
|
|
145
|
+
}
|
|
146
|
+
return out, nil
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
func (s *fakeTaskSystem) ApplyTaskConfig(_ context.Context, target task.Target, cfg config.RawValues) error {
|
|
150
|
+
key := target.Parent.Key
|
|
151
|
+
if target.Mailbox != nil {
|
|
152
|
+
key = target.Mailbox.Key
|
|
153
|
+
s.mu.Lock()
|
|
154
|
+
s.mailboxStatus[target.Mailbox.Key] = "In Progress"
|
|
155
|
+
s.mu.Unlock()
|
|
156
|
+
}
|
|
157
|
+
s.log.add("applyTaskConfig:" + key)
|
|
158
|
+
return nil
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
func (s *fakeTaskSystem) CompleteMailbox(_ context.Context, mb task.Mailbox) error {
|
|
162
|
+
if s.completeSlow > 0 {
|
|
163
|
+
s.log.add("completeMailboxStart:" + mb.Key)
|
|
164
|
+
time.Sleep(s.completeSlow) // a running activity cancellation cannot interrupt
|
|
165
|
+
}
|
|
166
|
+
if s.completeFail > 0 {
|
|
167
|
+
s.completeFail--
|
|
168
|
+
s.log.add("completeMailboxFail:" + mb.Key)
|
|
169
|
+
return errTransient
|
|
170
|
+
}
|
|
171
|
+
if s.completeConflict {
|
|
172
|
+
s.log.add("completeMailboxConflict:" + mb.Key)
|
|
173
|
+
return retry.ConflictError(errConflict)
|
|
174
|
+
}
|
|
175
|
+
s.mu.Lock()
|
|
176
|
+
s.mailboxStatus[mb.Key] = "Done"
|
|
177
|
+
s.mu.Unlock()
|
|
178
|
+
s.log.add("completeMailbox:" + mb.Key)
|
|
179
|
+
return nil
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
func (s *fakeTaskSystem) HasComment(_ context.Context, target task.Target, marker string) (bool, error) {
|
|
183
|
+
s.mu.Lock()
|
|
184
|
+
defer s.mu.Unlock()
|
|
185
|
+
for _, c := range s.comments {
|
|
186
|
+
if c.Marker == marker {
|
|
187
|
+
return true, nil
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
// Recovery cancellation-marker check on a parent.
|
|
191
|
+
if s.canceledParents[target.Parent.Key] && hasSuffixStr(marker, ":cancellation") {
|
|
192
|
+
return true, nil
|
|
193
|
+
}
|
|
194
|
+
return false, nil
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
func (s *fakeTaskSystem) Comment(_ context.Context, target task.Target, body, marker string) error {
|
|
198
|
+
key := target.Parent.Key
|
|
199
|
+
if target.Mailbox != nil {
|
|
200
|
+
key = target.Mailbox.Key
|
|
201
|
+
}
|
|
202
|
+
if s.failComments {
|
|
203
|
+
s.log.add("commentFail:" + key)
|
|
204
|
+
return errTransient
|
|
205
|
+
}
|
|
206
|
+
s.mu.Lock()
|
|
207
|
+
s.comments = append(s.comments, recordedComment{Key: key, Body: body, Marker: marker})
|
|
208
|
+
s.mu.Unlock()
|
|
209
|
+
s.log.add("comment:" + key)
|
|
210
|
+
return nil
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
func (s *fakeTaskSystem) ResetForRecovery(_ context.Context, parent task.TicketRef, mbs []task.Mailbox, _ config.RawValues) error {
|
|
214
|
+
s.mu.Lock()
|
|
215
|
+
for _, mb := range mbs {
|
|
216
|
+
s.mailboxStatus[mb.Key] = "To Do"
|
|
217
|
+
}
|
|
218
|
+
s.resets = append(s.resets, parent.Key)
|
|
219
|
+
s.mu.Unlock()
|
|
220
|
+
s.log.add("resetForRecovery:" + parent.Key)
|
|
221
|
+
return nil
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
func (s *fakeTaskSystem) commentBodies(key string) []recordedComment {
|
|
225
|
+
s.mu.Lock()
|
|
226
|
+
defer s.mu.Unlock()
|
|
227
|
+
var out []recordedComment
|
|
228
|
+
for _, c := range s.comments {
|
|
229
|
+
if c.Key == key {
|
|
230
|
+
out = append(out, c)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return out
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// mailboxesSnapshot returns a copy of the per-parent mailbox map, safe for
|
|
237
|
+
// tests to iterate without holding the lock.
|
|
238
|
+
func (s *fakeTaskSystem) mailboxesSnapshot() map[string]task.Mailbox {
|
|
239
|
+
s.mu.Lock()
|
|
240
|
+
defer s.mu.Unlock()
|
|
241
|
+
out := map[string]task.Mailbox{}
|
|
242
|
+
for k, v := range s.mailboxes {
|
|
243
|
+
out[k] = v
|
|
244
|
+
}
|
|
245
|
+
return out
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
func (s *fakeTaskSystem) labelsFor(key string) []string {
|
|
249
|
+
s.mu.Lock()
|
|
250
|
+
defer s.mu.Unlock()
|
|
251
|
+
return append([]string{}, s.labels[key]...)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
func (s *fakeTaskSystem) mailboxStatusOf(key string) string {
|
|
255
|
+
s.mu.Lock()
|
|
256
|
+
defer s.mu.Unlock()
|
|
257
|
+
return s.mailboxStatus[key]
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
func (s *fakeTaskSystem) mailboxFor(parentKey, node string) (task.Mailbox, bool) {
|
|
261
|
+
s.mu.Lock()
|
|
262
|
+
defer s.mu.Unlock()
|
|
263
|
+
mb, ok := s.mailboxes[parentKey+"/"+node]
|
|
264
|
+
return mb, ok
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
func (s *fakeTaskSystem) setMailboxStatus(key, status string) {
|
|
268
|
+
s.mu.Lock()
|
|
269
|
+
s.mailboxStatus[key] = status
|
|
270
|
+
s.mu.Unlock()
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// seedMailbox pre-populates an existing mailbox (with labels) for recovery
|
|
274
|
+
// and reuse tests.
|
|
275
|
+
func (s *fakeTaskSystem) seedMailbox(parentKey string, mb task.Mailbox, labels []string) {
|
|
276
|
+
s.mu.Lock()
|
|
277
|
+
defer s.mu.Unlock()
|
|
278
|
+
s.mailboxes[parentKey+"/"+mb.Node] = mb
|
|
279
|
+
s.mailboxStatus[mb.Key] = "To Do"
|
|
280
|
+
s.labels[mb.Key] = append([]string{}, labels...)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// parentByKey returns a pollable parent by key (for label assertions).
|
|
284
|
+
func (s *fakeTaskSystem) parentByKey(key string) (task.Ticket, bool) {
|
|
285
|
+
s.mu.Lock()
|
|
286
|
+
defer s.mu.Unlock()
|
|
287
|
+
for _, p := range s.parentsToRecover {
|
|
288
|
+
if p.Key == key {
|
|
289
|
+
return p, true
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return task.Ticket{}, false
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// --- runner ---
|
|
296
|
+
|
|
297
|
+
type fakeRunner struct {
|
|
298
|
+
log *eventLog
|
|
299
|
+
|
|
300
|
+
mu sync.Mutex
|
|
301
|
+
envs map[string]runner.Environment
|
|
302
|
+
terminals map[string]*fakeTerminal // envID/title
|
|
303
|
+
cleaned []string
|
|
304
|
+
closedRun []string
|
|
305
|
+
createErr error
|
|
306
|
+
nextID int
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
type fakeTerminal struct {
|
|
310
|
+
term runner.Terminal
|
|
311
|
+
live bool
|
|
312
|
+
title string
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
func newFakeRunner(log *eventLog) *fakeRunner {
|
|
316
|
+
return &fakeRunner{
|
|
317
|
+
log: log,
|
|
318
|
+
envs: map[string]runner.Environment{},
|
|
319
|
+
terminals: map[string]*fakeTerminal{},
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
func (f *fakeRunner) DiscoverRepos(context.Context) ([]runner.RepoCandidate, error) {
|
|
324
|
+
return []runner.RepoCandidate{{Name: "payments", Path: "/srv/payments"}}, nil
|
|
325
|
+
}
|
|
326
|
+
func (f *fakeRunner) ValidateRepo(context.Context, string, string) error { return nil }
|
|
327
|
+
|
|
328
|
+
func (f *fakeRunner) EnsureEnvironment(_ context.Context, spec runner.RunSpec) (runner.Environment, error) {
|
|
329
|
+
f.mu.Lock()
|
|
330
|
+
defer f.mu.Unlock()
|
|
331
|
+
if e, ok := f.envs[string(spec.RunID)]; ok {
|
|
332
|
+
return e, nil
|
|
333
|
+
}
|
|
334
|
+
e := runner.Environment{ID: "env-" + string(spec.RunID), Path: spec.RepoPath}
|
|
335
|
+
f.envs[string(spec.RunID)] = e
|
|
336
|
+
f.log.add("ensureEnvironment:" + string(spec.RunID))
|
|
337
|
+
return e, nil
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
func (f *fakeRunner) FindTerminal(_ context.Context, env runner.Environment, title string) (runner.Terminal, bool, error) {
|
|
341
|
+
f.mu.Lock()
|
|
342
|
+
defer f.mu.Unlock()
|
|
343
|
+
f.log.add("findTerminal:" + title)
|
|
344
|
+
ft, ok := f.terminals[env.ID+"/"+title]
|
|
345
|
+
if !ok || !ft.live {
|
|
346
|
+
return runner.Terminal{}, false, nil
|
|
347
|
+
}
|
|
348
|
+
return ft.term, true, nil
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
func (f *fakeRunner) InspectTerminal(_ context.Context, terminal runner.Terminal) (runner.Terminal, bool, error) {
|
|
352
|
+
f.mu.Lock()
|
|
353
|
+
defer f.mu.Unlock()
|
|
354
|
+
f.log.add("inspectTerminal:" + terminal.ID)
|
|
355
|
+
for _, ft := range f.terminals {
|
|
356
|
+
if ft.term.ID == terminal.ID && ft.live {
|
|
357
|
+
return ft.term, true, nil
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return runner.Terminal{}, false, nil
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
func (f *fakeRunner) SendTerminal(_ context.Context, terminal runner.Terminal, text string) error {
|
|
364
|
+
f.log.add("sendTerminal:" + terminal.ID + ":" + text)
|
|
365
|
+
return nil
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
func (f *fakeRunner) CreateTerminal(ctx context.Context, env runner.Environment, title string, command runner.Command) (runner.Terminal, error) {
|
|
369
|
+
f.mu.Lock()
|
|
370
|
+
defer f.mu.Unlock()
|
|
371
|
+
if f.createErr != nil {
|
|
372
|
+
err := f.createErr
|
|
373
|
+
f.createErr = nil
|
|
374
|
+
return runner.Terminal{}, err
|
|
375
|
+
}
|
|
376
|
+
f.nextID++
|
|
377
|
+
t := runner.Terminal{ID: fmt.Sprintf("t-%d-%s", f.nextID, title), Title: title}
|
|
378
|
+
f.terminals[env.ID+"/"+title] = &fakeTerminal{term: t, live: true, title: title}
|
|
379
|
+
f.log.add("ensureTerminal:" + title)
|
|
380
|
+
f.log.add("createTerminal:" + title + ":" + command.Executable)
|
|
381
|
+
return t, nil
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
func (f *fakeRunner) EnsureTerminal(_ context.Context, env runner.Environment, title string, _ runner.Command) (runner.Terminal, error) {
|
|
385
|
+
f.mu.Lock()
|
|
386
|
+
defer f.mu.Unlock()
|
|
387
|
+
key := env.ID + "/" + title
|
|
388
|
+
if ft, ok := f.terminals[key]; ok && ft.live {
|
|
389
|
+
return ft.term, nil
|
|
390
|
+
}
|
|
391
|
+
t := runner.Terminal{ID: "t-" + key, Title: title}
|
|
392
|
+
f.terminals[key] = &fakeTerminal{term: t, live: true, title: title}
|
|
393
|
+
f.log.add("ensureTerminal:" + title)
|
|
394
|
+
return t, nil
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
func (f *fakeRunner) CloseTerminal(_ context.Context, t runner.Terminal) error {
|
|
398
|
+
f.mu.Lock()
|
|
399
|
+
defer f.mu.Unlock()
|
|
400
|
+
for _, ft := range f.terminals {
|
|
401
|
+
if ft.term.ID == t.ID {
|
|
402
|
+
ft.live = false
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
f.log.add("closeTerminal:" + t.Title)
|
|
406
|
+
return nil
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
func (f *fakeRunner) CloseTerminals(_ context.Context, spec runner.RunSpec) error {
|
|
410
|
+
f.mu.Lock()
|
|
411
|
+
defer f.mu.Unlock()
|
|
412
|
+
prefix := "env-" + string(spec.RunID) + "/"
|
|
413
|
+
for k, ft := range f.terminals {
|
|
414
|
+
if len(k) >= len(prefix) && k[:len(prefix)] == prefix {
|
|
415
|
+
ft.live = false
|
|
416
|
+
f.log.add("closeTerminals:" + ft.title)
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
f.closedRun = append(f.closedRun, string(spec.RunID))
|
|
420
|
+
// Environment/workspace preserved.
|
|
421
|
+
return nil
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
func (f *fakeRunner) CleanupRun(_ context.Context, spec runner.RunSpec) error {
|
|
425
|
+
f.mu.Lock()
|
|
426
|
+
defer f.mu.Unlock()
|
|
427
|
+
prefix := "env-" + string(spec.RunID) + "/"
|
|
428
|
+
for k := range f.terminals {
|
|
429
|
+
if len(k) >= len(prefix) && k[:len(prefix)] == prefix {
|
|
430
|
+
delete(f.terminals, k)
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
delete(f.envs, string(spec.RunID))
|
|
434
|
+
f.cleaned = append(f.cleaned, string(spec.RunID))
|
|
435
|
+
f.log.add("cleanupRun:" + string(spec.RunID))
|
|
436
|
+
return nil
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
func (f *fakeRunner) liveTerminals() int {
|
|
440
|
+
f.mu.Lock()
|
|
441
|
+
defer f.mu.Unlock()
|
|
442
|
+
n := 0
|
|
443
|
+
for _, ft := range f.terminals {
|
|
444
|
+
if ft.live {
|
|
445
|
+
n++
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return n
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// killTerminals marks every terminal unusable (simulates terminal death for
|
|
452
|
+
// reconcile tests).
|
|
453
|
+
func (f *fakeRunner) killTerminals() {
|
|
454
|
+
f.mu.Lock()
|
|
455
|
+
defer f.mu.Unlock()
|
|
456
|
+
for _, ft := range f.terminals {
|
|
457
|
+
ft.live = false
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// --- harness ---
|
|
462
|
+
|
|
463
|
+
type fakeHarness struct {
|
|
464
|
+
log *eventLog
|
|
465
|
+
|
|
466
|
+
mu sync.Mutex
|
|
467
|
+
validated []string
|
|
468
|
+
sessions map[string]harness.Session
|
|
469
|
+
reconcileNudge int // nudges sent to idle live HITL sessions (must stay 0)
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
func newFakeHarness(log *eventLog) *fakeHarness {
|
|
473
|
+
return &fakeHarness{log: log, sessions: map[string]harness.Session{}}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
func (f *fakeHarness) ValidateAgent(_ context.Context, _, agent string) error {
|
|
477
|
+
f.mu.Lock()
|
|
478
|
+
f.validated = append(f.validated, agent)
|
|
479
|
+
f.mu.Unlock()
|
|
480
|
+
f.log.add("validateAgent:" + agent)
|
|
481
|
+
return nil
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
func (f *fakeHarness) FindSession(_ context.Context, _, title string) (harness.Session, bool, error) {
|
|
485
|
+
f.mu.Lock()
|
|
486
|
+
defer f.mu.Unlock()
|
|
487
|
+
f.log.add("findSession:" + title)
|
|
488
|
+
s, ok := f.sessions[title]
|
|
489
|
+
return s, ok, nil
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
func (f *fakeHarness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error) {
|
|
493
|
+
f.log.add("buildCommand:" + spec.Node + ":" + string(spec.NodeVisitID) + ":resume=" + spec.ResumeID)
|
|
494
|
+
return runner.Command{Executable: "opencode"}, nil
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
func hasPrefix(s, prefix string) bool {
|
|
498
|
+
return len(s) >= len(prefix) && s[:len(prefix)] == prefix
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
func hasSuffixStr(s, suffix string) bool {
|
|
502
|
+
return len(s) >= len(suffix) && s[len(s)-len(suffix):] == suffix
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// transientError is a Kind=Transient failure.
|
|
506
|
+
type transientError struct{ msg string }
|
|
507
|
+
|
|
508
|
+
func (e *transientError) Error() string { return e.msg }
|
|
509
|
+
|
|
510
|
+
var errTransient = &transientError{msg: "jira unavailable"}
|
|
511
|
+
|
|
512
|
+
// conflictError is wrapped by retry.ConflictError for Kind=Conflict.
|
|
513
|
+
type conflictError struct{ msg string }
|
|
514
|
+
|
|
515
|
+
func (e *conflictError) Error() string { return e.msg }
|
|
516
|
+
|
|
517
|
+
var errConflict = &conflictError{msg: "human moved mailbox"}
|