relay-flow 0.2.0-alpha → 0.2.2-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 +171 -26
- package/cmd/relay-flow/beads_composition_test.go +451 -0
- package/cmd/relay-flow/commands_test.go +593 -15
- package/cmd/relay-flow/main.go +356 -119
- package/cmd/relay-flow/scenario_test.go +246 -35
- package/cmd/relay-flow/serve.go +5 -2
- package/examples/beads-workflow.yaml +74 -0
- package/examples/default-story-workflow.yaml +88 -0
- package/go.mod +2 -1
- package/go.sum +2 -0
- package/internal/config/config.go +13 -2
- package/internal/config/merge_test.go +18 -0
- package/internal/execution/goworkflows/activities.go +136 -87
- package/internal/execution/goworkflows/end_feedback_test.go +124 -0
- package/internal/execution/goworkflows/engine.go +41 -8
- package/internal/execution/goworkflows/engine_test.go +100 -22
- package/internal/execution/goworkflows/fakes_test.go +63 -25
- package/internal/execution/goworkflows/interpreter.go +45 -30
- package/internal/execution/goworkflows/mailbox_test.go +85 -0
- package/internal/execution/goworkflows/node_runtime_integration_test.go +12 -6
- package/internal/execution/goworkflows/node_runtime_test.go +72 -23
- package/internal/execution/goworkflows/recovery_test.go +7 -7
- package/internal/execution/goworkflows/report_contract_fixture_test.go +31 -0
- package/internal/execution/goworkflows/retry_log_test.go +11 -11
- package/internal/harness/contract_test.go +15 -0
- package/internal/harness/factory.go +25 -3
- package/internal/harness/harness.go +30 -4
- package/internal/harness/opencode/opencode.go +125 -10
- package/internal/harness/opencode/opencode_test.go +183 -0
- package/internal/harness/opencode/repo_setup.go +361 -0
- package/internal/harness/plugin_selection_test.go +5 -5
- package/internal/paths/paths.go +18 -16
- package/internal/recover/recover.go +11 -6
- package/internal/repo/repo.go +13 -0
- package/internal/repo/service.go +10 -0
- package/internal/repo/service_test.go +55 -6
- package/internal/router/router.go +3 -2
- package/internal/router/router_test.go +87 -0
- package/internal/run/manager.go +14 -1
- package/internal/run/run.go +6 -4
- package/internal/run/run_manager_test.go +21 -1
- package/internal/runner/contract_test.go +64 -26
- package/internal/runner/orca/orca.go +30 -54
- package/internal/runner/orca/orca_test.go +143 -4
- package/internal/runner/orca/orcacli/orcacli.go +5 -0
- package/internal/runner/orca/orcacli/orcacli_test.go +3 -0
- package/internal/runner/orca/orcacli/testdata/strict-orca.sh +2 -0
- package/internal/runner/runner.go +15 -8
- package/internal/task/auth_test.go +48 -0
- package/internal/task/beads/bdcli/bdcli.go +323 -0
- package/internal/task/beads/bdcli/bdcli_test.go +297 -0
- package/internal/task/beads/bdcli/testdata/array.json +1 -0
- package/internal/task/beads/bdcli/testdata/children.json +1 -0
- package/internal/task/beads/bdcli/testdata/claimed.json +1 -0
- package/internal/task/beads/bdcli/testdata/commented.json +1 -0
- package/internal/task/beads/bdcli/testdata/comments.json +1 -0
- package/internal/task/beads/bdcli/testdata/created.json +1 -0
- package/internal/task/beads/bdcli/testdata/object.json +1 -0
- package/internal/task/beads/bdcli/testdata/ready.json +1 -0
- package/internal/task/beads/bdcli/testdata/show.json +1 -0
- package/internal/task/beads/bdcli/testdata/strict-bd.sh +149 -0
- package/internal/task/beads/bdcli/testdata/updated.json +1 -0
- package/internal/task/beads/beads.go +840 -0
- package/internal/task/beads/beads_test.go +609 -0
- package/internal/task/beads/comments_test.go +242 -0
- package/internal/task/beads/config_compatibility_test.go +163 -0
- package/internal/task/beads/lifecycle_inheritance_test.go +168 -0
- package/internal/task/beads/repo_composition_test.go +232 -0
- package/internal/task/beads/runtime_config_test.go +81 -0
- package/internal/task/beads/status_compatibility_test.go +233 -0
- package/internal/task/beads/status_test.go +257 -0
- package/internal/task/beads/testdata/strict-bd-repo.sh +27 -0
- package/internal/task/beads/validation_test.go +110 -0
- package/internal/task/contract_test.go +12 -0
- package/internal/task/factory.go +52 -3
- package/internal/task/jira/auth.go +209 -0
- package/internal/task/jira/auth_test.go +160 -0
- package/internal/task/jira/effects_test.go +39 -0
- package/internal/task/jira/filters_test.go +96 -16
- package/internal/task/jira/helpers_test.go +29 -19
- package/internal/task/jira/jira.go +263 -90
- package/internal/task/jira/lifecycle_inheritance_test.go +172 -0
- package/internal/task/jira/normalize.go +32 -14
- package/internal/task/jira/rest/adf.go +165 -0
- package/internal/task/jira/rest/adf_test.go +60 -0
- package/internal/task/jira/rest/client.go +573 -0
- package/internal/task/jira/rest/client_test.go +381 -0
- package/internal/task/jira/templates_test.go +118 -0
- package/internal/task/jira/transition_defaults_test.go +22 -18
- package/internal/task/jira/validation_test.go +1 -1
- package/internal/task/task.go +32 -0
- package/internal/workflow/report_test.go +45 -0
- package/internal/workflow/workflow.go +9 -6
- package/internal/workflow/workflow_test.go +14 -12
- package/package.json +2 -1
- package/internal/task/jira/acli/acli.go +0 -306
- package/internal/task/jira/acli/acli_test.go +0 -208
- package/internal/task/jira/acli/testdata/acli_comments.json +0 -55
- package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +0 -1
- package/internal/task/jira/acli/testdata/search_invalid_status.txt +0 -1
- /package/internal/task/{jira/acli/testdata/search_success.json → beads/bdcli/testdata/empty.json} +0 -0
- /package/internal/task/jira/testdata/{acli_search.json → jira_search_issues.json} +0 -0
|
@@ -49,6 +49,24 @@ func TestMergeMapsRecursively(t *testing.T) {
|
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
func TestMergeRawValuesMapsRecursively(t *testing.T) {
|
|
53
|
+
defaults := config.RawValues{"templates": map[string]any{
|
|
54
|
+
"mailboxDescription": "default mailbox",
|
|
55
|
+
"summaryComment": "default summary",
|
|
56
|
+
}}
|
|
57
|
+
override := config.RawValues{"templates": config.RawValues{
|
|
58
|
+
"mailboxDescription": "custom mailbox",
|
|
59
|
+
}}
|
|
60
|
+
got := config.Merge(defaults, override)
|
|
61
|
+
templates, ok := got["templates"].(map[string]any)
|
|
62
|
+
if !ok {
|
|
63
|
+
t.Fatalf("templates = %#v", got["templates"])
|
|
64
|
+
}
|
|
65
|
+
if templates["mailboxDescription"] != "custom mailbox" || templates["summaryComment"] != "default summary" {
|
|
66
|
+
t.Fatalf("templates = %#v", templates)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
52
70
|
func TestMergeListReplaces(t *testing.T) {
|
|
53
71
|
root := config.RawValues{"labels": []any{"a", "b"}}
|
|
54
72
|
wf := config.RawValues{"labels": []any{"c"}}
|
|
@@ -2,7 +2,6 @@ package goworkflows
|
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
4
|
"context"
|
|
5
|
-
"errors"
|
|
6
5
|
"fmt"
|
|
7
6
|
"log/slog"
|
|
8
7
|
"sort"
|
|
@@ -21,10 +20,11 @@ import (
|
|
|
21
20
|
// Activities holds the replaceable dependencies shared by every durable
|
|
22
21
|
// activity. One Activities value is registered with the activity worker.
|
|
23
22
|
type Activities struct {
|
|
24
|
-
Repos
|
|
25
|
-
Runner
|
|
26
|
-
Harness
|
|
27
|
-
|
|
23
|
+
Repos *repo.Registry
|
|
24
|
+
Runner runner.Runner
|
|
25
|
+
Harness harness.Harness
|
|
26
|
+
TaskSystem string
|
|
27
|
+
Runs *RunProjection
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
func (a *Activities) taskSystem(repoName string) (task.System, error) {
|
|
@@ -51,6 +51,16 @@ func (a *Activities) EnsureMailboxes(ctx context.Context, w run.Work, specs []ta
|
|
|
51
51
|
if err != nil {
|
|
52
52
|
return nil, err
|
|
53
53
|
}
|
|
54
|
+
for i := range specs {
|
|
55
|
+
data := specs[i].TextData
|
|
56
|
+
data.RunID = string(w.RunID)
|
|
57
|
+
data.Repo = w.Repo
|
|
58
|
+
custom, err := sys.RenderText(task.TextMailboxDescription, data)
|
|
59
|
+
if err != nil {
|
|
60
|
+
return nil, fmt.Errorf("render mailbox %q description: %w", specs[i].Node, err)
|
|
61
|
+
}
|
|
62
|
+
specs[i].Description = appendText(custom, specs[i].Description)
|
|
63
|
+
}
|
|
54
64
|
return sys.EnsureMailboxes(ctx, w.Parent, w.Workflow, specs)
|
|
55
65
|
}
|
|
56
66
|
|
|
@@ -97,13 +107,23 @@ func (a *Activities) EnsureEnvironment(ctx context.Context, w run.Work, repoPath
|
|
|
97
107
|
return a.Runner.EnsureEnvironment(ctx, spec)
|
|
98
108
|
}
|
|
99
109
|
|
|
110
|
+
func (a *Activities) SetEnvironmentStatus(ctx context.Context, w run.Work, repoPath, status string) error {
|
|
111
|
+
spec := a.runSpec(w)
|
|
112
|
+
spec.RepoPath = repoPath
|
|
113
|
+
env, err := a.Runner.EnsureEnvironment(ctx, spec)
|
|
114
|
+
if err != nil {
|
|
115
|
+
return err
|
|
116
|
+
}
|
|
117
|
+
return a.Runner.SetEnvironmentStatus(ctx, env, status)
|
|
118
|
+
}
|
|
119
|
+
|
|
100
120
|
func (a *Activities) LoadNodeRuntime(ctx context.Context, id run.ID, node string) (NodeRuntime, error) {
|
|
101
121
|
return a.Runs.loadNodeRuntime(ctx, id, node)
|
|
102
122
|
}
|
|
103
123
|
|
|
104
124
|
// EnsureNodeRuntime uses only persisted terminal/session IDs on the normal
|
|
105
|
-
// path. A live terminal is rebound to the new visit; otherwise
|
|
106
|
-
//
|
|
125
|
+
// path. A live terminal is rebound to the new visit; otherwise EnsureTerminal
|
|
126
|
+
// creates a replacement and its direct ID is persisted immediately.
|
|
107
127
|
func (a *Activities) EnsureNodeRuntime(ctx context.Context, nw run.NodeWork, repoPath string, spec harness.LaunchSpec, rt NodeRuntime) error {
|
|
108
128
|
a.Runs.runtimeMu.Lock()
|
|
109
129
|
defer a.Runs.runtimeMu.Unlock()
|
|
@@ -124,81 +144,78 @@ func (a *Activities) EnsureNodeRuntime(ctx context.Context, nw run.NodeWork, rep
|
|
|
124
144
|
if err != nil {
|
|
125
145
|
return err
|
|
126
146
|
}
|
|
127
|
-
|
|
147
|
+
rs := a.runSpec(nw.Work)
|
|
148
|
+
rs.RepoPath = repoPath
|
|
149
|
+
env, err := a.Runner.EnsureEnvironment(ctx, rs)
|
|
150
|
+
if err != nil {
|
|
151
|
+
return err
|
|
152
|
+
}
|
|
153
|
+
status := runner.WorkspaceStatusInProgress
|
|
154
|
+
if spec.NodeType == workflow.NodeHITL {
|
|
155
|
+
status = runner.WorkspaceStatusInReview
|
|
156
|
+
}
|
|
157
|
+
if err := a.Runner.SetEnvironmentStatus(ctx, env, status); err != nil {
|
|
158
|
+
return err
|
|
159
|
+
}
|
|
128
160
|
// IDs come from the guarded current row; the activity input's prior visit
|
|
129
161
|
// is used only to decide whether a live process needs rebinding.
|
|
130
162
|
rt.TerminalID = currentRuntime.TerminalID
|
|
131
163
|
rt.SessionID = currentRuntime.SessionID
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
164
|
+
spec.ResumeID = rt.SessionID
|
|
165
|
+
stored := runner.Terminal{ID: rt.TerminalID, Title: spec.Title}
|
|
166
|
+
terminal, live, err := a.Runner.FindTerminal(ctx, stored)
|
|
167
|
+
if err != nil {
|
|
168
|
+
return err
|
|
169
|
+
}
|
|
170
|
+
if live {
|
|
171
|
+
if err := a.Runs.replaceNodeRuntime(ctx, nw.RunID, nw.Node, nw.NodeVisitID,
|
|
172
|
+
terminal.ID, rt.SessionID, rt.SessionID); err != nil {
|
|
173
|
+
return err
|
|
138
174
|
}
|
|
139
|
-
if
|
|
140
|
-
// Same-visit retry/restart
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
// Direct use failed. Close the known live terminal before replacing
|
|
155
|
-
// it so a second agent process cannot be left running.
|
|
156
|
-
if err := a.Runner.CloseTerminal(ctx, terminal); err != nil {
|
|
157
|
-
return err
|
|
158
|
-
}
|
|
159
|
-
freshSession = true
|
|
175
|
+
if !revisit {
|
|
176
|
+
// Same-visit retry/restart is silent: do not render, build, or send.
|
|
177
|
+
return nil
|
|
178
|
+
}
|
|
179
|
+
prompt, err := a.Harness.RenderPrompt(harness.PromptFeedback, spec.PromptData, spec.NudgePrompt)
|
|
180
|
+
if err != nil {
|
|
181
|
+
return err
|
|
182
|
+
}
|
|
183
|
+
if err := a.Runner.SendTerminal(ctx, terminal, prompt); err == nil {
|
|
184
|
+
return nil
|
|
185
|
+
}
|
|
186
|
+
// Direct use failed. Close the known live terminal before replacing it
|
|
187
|
+
// so a second agent process cannot be left running.
|
|
188
|
+
if err := a.Runner.CloseTerminal(ctx, terminal); err != nil {
|
|
189
|
+
return err
|
|
160
190
|
}
|
|
161
191
|
}
|
|
162
192
|
|
|
163
|
-
|
|
164
|
-
|
|
193
|
+
// An initial or replacement terminal resumes the stored session and
|
|
194
|
+
// receives the rendered initial prompt. Same-visit replacements omit the
|
|
195
|
+
// node nudge; a new visit includes it.
|
|
196
|
+
nudge := ""
|
|
197
|
+
if rt.NodeVisitID == "" || revisit {
|
|
198
|
+
nudge = spec.NudgePrompt
|
|
165
199
|
}
|
|
166
|
-
|
|
167
|
-
if !hadRuntime || revisit {
|
|
168
|
-
spec.Prompt = appendPrompt(spec.Prompt, spec.NudgePrompt)
|
|
169
|
-
}
|
|
170
|
-
cmd, err := a.Harness.BuildCommand(spec)
|
|
200
|
+
spec.Prompt, err = a.Harness.RenderPrompt(harness.PromptInitial, spec.PromptData, nudge)
|
|
171
201
|
if err != nil {
|
|
172
202
|
return err
|
|
173
203
|
}
|
|
174
|
-
|
|
175
|
-
rs.RepoPath = repoPath
|
|
176
|
-
env, err := a.Runner.EnsureEnvironment(ctx, rs)
|
|
204
|
+
cmd, err := a.Harness.BuildCommand(spec)
|
|
177
205
|
if err != nil {
|
|
178
206
|
return err
|
|
179
207
|
}
|
|
180
|
-
|
|
181
|
-
sessionID := rt.SessionID
|
|
182
|
-
if errors.Is(err, runner.ErrSessionUnavailable) && rt.SessionID != "" {
|
|
183
|
-
spec.ResumeID = ""
|
|
184
|
-
cmd, buildErr := a.Harness.BuildCommand(spec)
|
|
185
|
-
if buildErr != nil {
|
|
186
|
-
return buildErr
|
|
187
|
-
}
|
|
188
|
-
terminal, err = a.Runner.CreateTerminal(ctx, env, spec.Title, cmd)
|
|
189
|
-
sessionID = ""
|
|
190
|
-
}
|
|
191
|
-
if freshSession {
|
|
192
|
-
sessionID = ""
|
|
193
|
-
}
|
|
208
|
+
replacement, err := a.Runner.EnsureTerminal(ctx, env, stored, spec.Title, cmd)
|
|
194
209
|
if err != nil {
|
|
195
210
|
return err
|
|
196
211
|
}
|
|
212
|
+
// Persist a newly created/replacement handle before any later external
|
|
213
|
+
// effect. Runtime session registration may update SessionID independently.
|
|
197
214
|
if err := a.Runs.replaceNodeRuntime(ctx, nw.RunID, nw.Node, nw.NodeVisitID,
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
215
|
+
replacement.ID, rt.SessionID, rt.SessionID); err != nil {
|
|
216
|
+
if replacement.ID != rt.TerminalID {
|
|
217
|
+
_ = a.Runner.CloseTerminal(ctx, replacement)
|
|
218
|
+
}
|
|
202
219
|
return err
|
|
203
220
|
}
|
|
204
221
|
return nil
|
|
@@ -274,7 +291,14 @@ func (a *Activities) Comment(ctx context.Context, repoName string, cw run.Commen
|
|
|
274
291
|
if err != nil {
|
|
275
292
|
return err
|
|
276
293
|
}
|
|
277
|
-
|
|
294
|
+
body := cw.Body
|
|
295
|
+
if cw.TextKind != "" {
|
|
296
|
+
body, err = sys.RenderText(cw.TextKind, cw.TextData)
|
|
297
|
+
if err != nil {
|
|
298
|
+
return fmt.Errorf("render %s: %w", cw.TextKind, err)
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
if err := sys.Comment(ctx, cw.Item, body, cw.Marker); err != nil {
|
|
278
302
|
return err
|
|
279
303
|
}
|
|
280
304
|
// Log AFTER the write succeeds so the line is a true effect record.
|
|
@@ -393,19 +417,7 @@ func (a *Activities) ProjectionUpdateRetry(ctx context.Context, id run.ID, statu
|
|
|
393
417
|
// description, and every legal route with its when explanation.
|
|
394
418
|
func MailboxSpecForNode(wf *workflow.Workflow, ticketKey, name string, n workflow.Node) task.MailboxSpec {
|
|
395
419
|
var b strings.Builder
|
|
396
|
-
|
|
397
|
-
ticketKey, name, n.Type, n.Agent, n.Description)
|
|
398
|
-
if n.Type == workflow.NodeHITL {
|
|
399
|
-
b.WriteString(`
|
|
400
|
-
|
|
401
|
-
1. Discuss the task with the human, request the PR link or any missing context, and review the changes. Do not make code changes.
|
|
402
|
-
2. Resolve questions and requested review updates through normal conversation until the human is satisfied with the review.
|
|
403
|
-
3. Present the complete report through OpenCode's Question tool with exactly two options: Approve and Reject.
|
|
404
|
-
4. If approved, output the report verbatim. If rejected, return to step 1.`)
|
|
405
|
-
}
|
|
406
|
-
b.WriteString(`
|
|
407
|
-
|
|
408
|
-
Required report format:
|
|
420
|
+
b.WriteString(`Required report format:
|
|
409
421
|
|
|
410
422
|
STATUS: success | failure
|
|
411
423
|
NEXT STEP: <one valid node name>
|
|
@@ -424,7 +436,9 @@ REQUIRED ACTIONS:
|
|
|
424
436
|
RELEVANT CONTEXT:
|
|
425
437
|
EXPECTED RESULT:
|
|
426
438
|
|
|
427
|
-
Every field is required; use None for an intentionally empty section. COMMITS must contain the relevant commit IDs or None.
|
|
439
|
+
Every field is required; use None for an intentionally empty section. COMMITS must contain the relevant commit IDs or None.
|
|
440
|
+
|
|
441
|
+
Node names identify workflow stages; they are not task-system statuses. STATUS describes whether the work at this node succeeded or failed, not the status of the parent or mailbox. NEXT STEP must name exactly one target listed below for that STATUS. Submit one report only: its SUMMARY is written to this current mailbox, while its FEEDBACK is written only to the selected next node's mailbox. For review nodes, put requested changes in FEEDBACK and select the node responsible for acting on them. Relay-flow and the task system own parent and mailbox status changes. When NEXT STEP is end, every FEEDBACK field must be None.`)
|
|
428
442
|
writeRoutes := func(label string, routes []workflow.Route) {
|
|
429
443
|
if len(routes) == 0 {
|
|
430
444
|
return
|
|
@@ -440,11 +454,20 @@ Every field is required; use None for an intentionally empty section. COMMITS mu
|
|
|
440
454
|
}
|
|
441
455
|
writeRoutes("On success", n.OnSuccess)
|
|
442
456
|
writeRoutes("On failure", n.OnFailure)
|
|
457
|
+
successRoutes := routesText(n.OnSuccess)
|
|
458
|
+
failureRoutes := routesText(n.OnFailure)
|
|
443
459
|
return task.MailboxSpec{
|
|
444
460
|
Node: name,
|
|
445
461
|
Title: ticketKey + ":" + name,
|
|
446
462
|
Description: b.String(),
|
|
447
463
|
TaskConfig: n.TaskConfig,
|
|
464
|
+
TextData: task.TextData{
|
|
465
|
+
Ticket: ticketKey, Workflow: wf.Name, Node: name, NodeType: string(n.Type),
|
|
466
|
+
Agent: n.Agent, NodeDescription: n.Description,
|
|
467
|
+
NextSteps: nextStepsText(append(append([]workflow.Route{}, n.OnSuccess...), n.OnFailure...)),
|
|
468
|
+
SuccessRoutes: successRoutes, FailureRoutes: failureRoutes,
|
|
469
|
+
Mailbox: ticketKey + ":" + name,
|
|
470
|
+
},
|
|
448
471
|
}
|
|
449
472
|
}
|
|
450
473
|
|
|
@@ -464,20 +487,46 @@ func MailboxSpecs(wf *workflow.Workflow, ticketKey string) []task.MailboxSpec {
|
|
|
464
487
|
return out
|
|
465
488
|
}
|
|
466
489
|
|
|
467
|
-
//
|
|
468
|
-
|
|
469
|
-
|
|
490
|
+
// RenderMailboxSpecs asks the selected task system to render customizable
|
|
491
|
+
// mailbox text, then appends the fixed report contract and legal routes.
|
|
492
|
+
// It is shared by normal execution and explicit database-loss recovery.
|
|
493
|
+
func RenderMailboxSpecs(sys task.System, w run.Work, wf *workflow.Workflow) ([]task.MailboxSpec, error) {
|
|
494
|
+
specs := MailboxSpecs(wf, w.Parent.Key)
|
|
495
|
+
for i := range specs {
|
|
496
|
+
data := specs[i].TextData
|
|
497
|
+
data.RunID = string(w.RunID)
|
|
498
|
+
data.Repo = w.Repo
|
|
499
|
+
custom, err := sys.RenderText(task.TextMailboxDescription, data)
|
|
500
|
+
if err != nil {
|
|
501
|
+
return nil, fmt.Errorf("render mailbox %q description: %w", specs[i].Node, err)
|
|
502
|
+
}
|
|
503
|
+
specs[i].Description = appendText(custom, specs[i].Description)
|
|
504
|
+
}
|
|
505
|
+
return specs, nil
|
|
470
506
|
}
|
|
471
507
|
|
|
472
|
-
func
|
|
473
|
-
|
|
508
|
+
func routesText(routes []workflow.Route) string {
|
|
509
|
+
var b strings.Builder
|
|
510
|
+
for i, route := range routes {
|
|
511
|
+
if i > 0 {
|
|
512
|
+
b.WriteString("\n")
|
|
513
|
+
}
|
|
514
|
+
b.WriteString(route.Target)
|
|
515
|
+
if route.When != "" {
|
|
516
|
+
b.WriteString(" — when: " + route.When)
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
return b.String()
|
|
474
520
|
}
|
|
475
521
|
|
|
476
|
-
func
|
|
477
|
-
if
|
|
478
|
-
return
|
|
522
|
+
func appendText(first, second string) string {
|
|
523
|
+
if first == "" {
|
|
524
|
+
return second
|
|
525
|
+
}
|
|
526
|
+
if second == "" {
|
|
527
|
+
return first
|
|
479
528
|
}
|
|
480
|
-
return
|
|
529
|
+
return first + "\n\n" + second
|
|
481
530
|
}
|
|
482
531
|
|
|
483
532
|
// mergeTaskConfig overlays node task config onto workflow task config using
|
|
@@ -0,0 +1,124 @@
|
|
|
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
|
+
func TestEndSelectionSkipsFeedbackCommentActivity(t *testing.T) {
|
|
17
|
+
log := newEventLog()
|
|
18
|
+
sys := newFakeTaskSystem(log)
|
|
19
|
+
engine := newEndFeedbackTestEngine(t, sys)
|
|
20
|
+
wf := workflow.Workflow{
|
|
21
|
+
Name: "endFeedback",
|
|
22
|
+
Repos: []string{"payments"},
|
|
23
|
+
Nodes: map[string]workflow.Node{
|
|
24
|
+
"start": {OnSuccess: []workflow.Route{{Target: "implement"}}},
|
|
25
|
+
"implement": {
|
|
26
|
+
Type: workflow.NodeAgent, Agent: "build", Description: "implement",
|
|
27
|
+
OnSuccess: []workflow.Route{{Target: "end"}},
|
|
28
|
+
OnFailure: []workflow.Route{{Target: "implement"}},
|
|
29
|
+
},
|
|
30
|
+
"end": {},
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
rid := identity.NewRunID("payments", wf.Name, "PAY-101")
|
|
34
|
+
if _, err := engine.EnsureRun(context.Background(), run.Start{
|
|
35
|
+
ID: rid, Repo: "payments", RepoPath: "/srv/payments", Workflow: wf,
|
|
36
|
+
Ticket: task.TicketRef{ID: "demo-parent", Key: "demo-parent", Title: "parent"},
|
|
37
|
+
}); err != nil {
|
|
38
|
+
t.Fatal(err)
|
|
39
|
+
}
|
|
40
|
+
waitForEndFeedbackTest(t, engine, rid, func(r run.Run) bool {
|
|
41
|
+
return r.CurrentNode == "implement"
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
if _, err := engine.SubmitReport(context.Background(), run.ReportRequest{
|
|
45
|
+
RunID: rid, Node: "implement", ReportID: "end-feedback-test",
|
|
46
|
+
Report: endFeedbackSuccessReport(),
|
|
47
|
+
}); err != nil {
|
|
48
|
+
t.Fatal(err)
|
|
49
|
+
}
|
|
50
|
+
waitForEndFeedbackTest(t, engine, rid, func(r run.Run) bool {
|
|
51
|
+
return r.State == run.StateCompleted
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
events := log.all()
|
|
55
|
+
if !hasEvent(events, "comment:demo-parent-implement") {
|
|
56
|
+
t.Fatalf("current summary comment missing: %v", events)
|
|
57
|
+
}
|
|
58
|
+
if countEvent(events, "comment:demo-parent-implement") != 1 {
|
|
59
|
+
t.Fatalf("summary comment count = %d; events=%v", countEvent(events, "comment:demo-parent-implement"), events)
|
|
60
|
+
}
|
|
61
|
+
if hasEventPrefixExcept(events, "comment:", "comment:demo-parent-implement") {
|
|
62
|
+
t.Fatalf("end selection wrote feedback comment; events=%v", events)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
func endFeedbackSuccessReport() workflow.Report {
|
|
67
|
+
none := "None"
|
|
68
|
+
return workflow.Report{
|
|
69
|
+
Status: workflow.OutcomeSuccess, NextStep: "end",
|
|
70
|
+
Summary: workflow.Summary{
|
|
71
|
+
Completed: "done", Commits: "abc123", NotCompleted: none,
|
|
72
|
+
IssuesDiscovered: none, Verification: "tested", Notes: none,
|
|
73
|
+
},
|
|
74
|
+
Feedback: workflow.Feedback{
|
|
75
|
+
ReasonForNextStep: none, RequiredActions: none,
|
|
76
|
+
RelevantContext: none, ExpectedResult: none,
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
func newEndFeedbackTestEngine(t *testing.T, sys task.System) *goworkflows.Engine {
|
|
82
|
+
t.Helper()
|
|
83
|
+
return newEngine(t, goworkflows.Dependencies{
|
|
84
|
+
Repos: repoRegistryWith("payments", sys),
|
|
85
|
+
Runner: newFakeRunner(newEventLog()),
|
|
86
|
+
Harness: newFakeHarness(newEventLog()),
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
func waitForEndFeedbackTest(t *testing.T, engine *goworkflows.Engine, id run.ID, predicate func(run.Run) bool) {
|
|
91
|
+
t.Helper()
|
|
92
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
93
|
+
r, err := engine.GetRun(context.Background(), id)
|
|
94
|
+
return err == nil && predicate(r)
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
func hasEvent(events []string, want string) bool {
|
|
99
|
+
for _, event := range events {
|
|
100
|
+
if event == want {
|
|
101
|
+
return true
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return false
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
func countEvent(events []string, want string) int {
|
|
108
|
+
count := 0
|
|
109
|
+
for _, event := range events {
|
|
110
|
+
if event == want {
|
|
111
|
+
count++
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return count
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
func hasEventPrefixExcept(events []string, prefix, allowed string) bool {
|
|
118
|
+
for _, event := range events {
|
|
119
|
+
if strings.HasPrefix(event, prefix) && event != allowed {
|
|
120
|
+
return true
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return false
|
|
124
|
+
}
|
|
@@ -32,9 +32,10 @@ import (
|
|
|
32
32
|
|
|
33
33
|
// Dependencies carries the replaceable boundaries used by activities.
|
|
34
34
|
type Dependencies struct {
|
|
35
|
-
Repos
|
|
36
|
-
Runner
|
|
37
|
-
Harness
|
|
35
|
+
Repos *repo.Registry
|
|
36
|
+
Runner runner.Runner
|
|
37
|
+
Harness harness.Harness
|
|
38
|
+
TaskSystem string
|
|
38
39
|
|
|
39
40
|
// RetentionDays bounds completed/canceled run retention; zero uses the
|
|
40
41
|
// machine default of 30 days.
|
|
@@ -91,6 +92,23 @@ func InitDatabase(path string) error {
|
|
|
91
92
|
return nil
|
|
92
93
|
}
|
|
93
94
|
|
|
95
|
+
// HasNonterminalRuns inspects an existing database without migrating or
|
|
96
|
+
// otherwise modifying it. It is used by init --force before config changes.
|
|
97
|
+
func HasNonterminalRuns(path string) (bool, error) {
|
|
98
|
+
db, err := sql.Open("sqlite", fmt.Sprintf("file:%s?mode=ro", path))
|
|
99
|
+
if err != nil {
|
|
100
|
+
return false, fmt.Errorf("open %s: %w", path, err)
|
|
101
|
+
}
|
|
102
|
+
defer db.Close()
|
|
103
|
+
var active bool
|
|
104
|
+
if err := db.QueryRow(`SELECT EXISTS(
|
|
105
|
+
SELECT 1 FROM relay_runs WHERE state NOT IN ('completed', 'canceled')
|
|
106
|
+
)`).Scan(&active); err != nil {
|
|
107
|
+
return false, fmt.Errorf("inspect %s: %w", path, err)
|
|
108
|
+
}
|
|
109
|
+
return active, nil
|
|
110
|
+
}
|
|
111
|
+
|
|
94
112
|
// New opens the SQLite database at path (created with mode 0600 when
|
|
95
113
|
// missing), migrates the relay_runs projection, and constructs the engine.
|
|
96
114
|
// A corrupt database file fails here.
|
|
@@ -130,10 +148,11 @@ func New(path string, deps Dependencies) (*Engine, error) {
|
|
|
130
148
|
return nil, fmt.Errorf("migrate relay_runs: %w", err)
|
|
131
149
|
}
|
|
132
150
|
activities := &Activities{
|
|
133
|
-
Repos:
|
|
134
|
-
Runner:
|
|
135
|
-
Harness:
|
|
136
|
-
|
|
151
|
+
Repos: deps.Repos,
|
|
152
|
+
Runner: deps.Runner,
|
|
153
|
+
Harness: deps.Harness,
|
|
154
|
+
TaskSystem: deps.TaskSystem,
|
|
155
|
+
Runs: proj,
|
|
137
156
|
}
|
|
138
157
|
retention := 30 * 24 * time.Hour
|
|
139
158
|
if deps.RetentionDays > 0 {
|
|
@@ -204,6 +223,7 @@ func (e *Engine) registerActivities() error {
|
|
|
204
223
|
a.ValidateAgents,
|
|
205
224
|
a.ApplyTaskConfig,
|
|
206
225
|
a.EnsureEnvironment,
|
|
226
|
+
a.SetEnvironmentStatus,
|
|
207
227
|
a.LoadNodeRuntime,
|
|
208
228
|
a.EnsureNodeRuntime,
|
|
209
229
|
a.CloseTerminals,
|
|
@@ -291,6 +311,19 @@ func (e *Engine) EnsureRun(ctx context.Context, start run.Start) (bool, error) {
|
|
|
291
311
|
if err != nil {
|
|
292
312
|
return false, err
|
|
293
313
|
}
|
|
314
|
+
if r.State != run.StateCompleted && r.State != run.StateCanceled && r.State != run.StateCanceling {
|
|
315
|
+
if _, err := e.instance(ctx, start.ID); errors.Is(err, sql.ErrNoRows) {
|
|
316
|
+
start.Runtime = e.runtime
|
|
317
|
+
if _, err := e.client.CreateWorkflowInstance(ctx,
|
|
318
|
+
client.WorkflowInstanceOptions{InstanceID: string(start.ID)},
|
|
319
|
+
e.activities.TicketWorkflow, start); err != nil && !errors.Is(err, backend.ErrInstanceAlreadyExists) {
|
|
320
|
+
return false, fmt.Errorf("create missing workflow instance %s: %w", start.ID, err)
|
|
321
|
+
}
|
|
322
|
+
return true, nil
|
|
323
|
+
} else if err != nil {
|
|
324
|
+
return false, err
|
|
325
|
+
}
|
|
326
|
+
}
|
|
294
327
|
// Existing run: reconcile only an active run at a work node.
|
|
295
328
|
if r.State == run.StateCompleted || r.State == run.StateCanceled || r.State == run.StateCanceling {
|
|
296
329
|
return false, nil
|
|
@@ -304,7 +337,7 @@ func (e *Engine) EnsureRun(ctx context.Context, start run.Start) (bool, error) {
|
|
|
304
337
|
}
|
|
305
338
|
ok := false
|
|
306
339
|
if runtime.TerminalID != "" {
|
|
307
|
-
_, ok, _ = e.activities.Runner.
|
|
340
|
+
_, ok, _ = e.activities.Runner.FindTerminal(ctx, runner.Terminal{
|
|
308
341
|
ID: runtime.TerminalID, Title: r.Ticket.Key + ":" + r.CurrentNode,
|
|
309
342
|
})
|
|
310
343
|
}
|