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,490 @@
|
|
|
1
|
+
package goworkflows
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"fmt"
|
|
7
|
+
"log/slog"
|
|
8
|
+
"sort"
|
|
9
|
+
"strings"
|
|
10
|
+
"time"
|
|
11
|
+
|
|
12
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
13
|
+
"github.com/rajpopat27/relay-flow/internal/harness"
|
|
14
|
+
"github.com/rajpopat27/relay-flow/internal/repo"
|
|
15
|
+
"github.com/rajpopat27/relay-flow/internal/run"
|
|
16
|
+
"github.com/rajpopat27/relay-flow/internal/runner"
|
|
17
|
+
"github.com/rajpopat27/relay-flow/internal/task"
|
|
18
|
+
"github.com/rajpopat27/relay-flow/internal/workflow"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
// Activities holds the replaceable dependencies shared by every durable
|
|
22
|
+
// activity. One Activities value is registered with the activity worker.
|
|
23
|
+
type Activities struct {
|
|
24
|
+
Repos *repo.Registry
|
|
25
|
+
Runner runner.Runner
|
|
26
|
+
Harness harness.Harness
|
|
27
|
+
Runs *RunProjection
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
func (a *Activities) taskSystem(repoName string) (task.System, error) {
|
|
31
|
+
rp, ok := a.Repos.Get(repoName)
|
|
32
|
+
if !ok {
|
|
33
|
+
return nil, fmt.Errorf("repo %q is not registered", repoName)
|
|
34
|
+
}
|
|
35
|
+
return rp.TaskSystem, nil
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
func (a *Activities) runSpec(w run.Work) runner.RunSpec {
|
|
39
|
+
return runner.RunSpec{
|
|
40
|
+
RunID: w.RunID,
|
|
41
|
+
RepoName: w.Repo,
|
|
42
|
+
RepoPath: "",
|
|
43
|
+
TicketKey: w.Parent.Key,
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// EnsureMailboxes ensures one mailbox per work node and returns the
|
|
48
|
+
// node-to-mailbox map.
|
|
49
|
+
func (a *Activities) EnsureMailboxes(ctx context.Context, w run.Work, specs []task.MailboxSpec) (map[string]task.Mailbox, error) {
|
|
50
|
+
sys, err := a.taskSystem(w.Repo)
|
|
51
|
+
if err != nil {
|
|
52
|
+
return nil, err
|
|
53
|
+
}
|
|
54
|
+
return sys.EnsureMailboxes(ctx, w.Parent, w.Workflow, specs)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ValidateAgents validates every referenced agent on the repo.
|
|
58
|
+
func (a *Activities) ValidateAgents(ctx context.Context, repoPath string, agents []string) error {
|
|
59
|
+
for _, agent := range agents {
|
|
60
|
+
if err := a.Harness.ValidateAgent(ctx, repoPath, agent); err != nil {
|
|
61
|
+
return fmt.Errorf("validate agent %q: %w", agent, err)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return nil
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ApplyTaskConfig applies adapter-owned task config to the parent and
|
|
68
|
+
// optional mailbox target.
|
|
69
|
+
func (a *Activities) ApplyTaskConfig(ctx context.Context, w run.Work, node string, mailbox *task.Mailbox, cfg map[string]any) error {
|
|
70
|
+
sys, err := a.taskSystem(w.Repo)
|
|
71
|
+
if err != nil {
|
|
72
|
+
return err
|
|
73
|
+
}
|
|
74
|
+
// Adapters with lifecycle-dependent taskConfig defaults (e.g. Jira
|
|
75
|
+
// transitionTo) expose them via task.LifecycleDefaults; merge them as
|
|
76
|
+
// the lowest layer under the effective node config so explicit values
|
|
77
|
+
// win. Core never learns adapter vocabulary.
|
|
78
|
+
if d, ok := sys.(task.LifecycleDefaults); ok {
|
|
79
|
+
var defaults config.RawValues
|
|
80
|
+
switch {
|
|
81
|
+
case node == "start":
|
|
82
|
+
defaults = d.StartDefaults()
|
|
83
|
+
case node == "end":
|
|
84
|
+
defaults = d.EndDefaults()
|
|
85
|
+
default:
|
|
86
|
+
defaults = d.WorkDefaults()
|
|
87
|
+
}
|
|
88
|
+
cfg = map[string]any(config.Merge(defaults, cfg))
|
|
89
|
+
}
|
|
90
|
+
return sys.ApplyTaskConfig(ctx, task.Target{Parent: w.Parent, Mailbox: mailbox}, cfg)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// EnsureEnvironment ensures the ticket-scoped runner environment.
|
|
94
|
+
func (a *Activities) EnsureEnvironment(ctx context.Context, w run.Work, repoPath string) (runner.Environment, error) {
|
|
95
|
+
spec := a.runSpec(w)
|
|
96
|
+
spec.RepoPath = repoPath
|
|
97
|
+
return a.Runner.EnsureEnvironment(ctx, spec)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
func (a *Activities) LoadNodeRuntime(ctx context.Context, id run.ID, node string) (NodeRuntime, error) {
|
|
101
|
+
return a.Runs.loadNodeRuntime(ctx, id, node)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// EnsureNodeRuntime uses only persisted terminal/session IDs on the normal
|
|
105
|
+
// path. A live terminal is rebound to the new visit; otherwise a fresh
|
|
106
|
+
// terminal is created and its direct IDs atomically replace the old binding.
|
|
107
|
+
func (a *Activities) EnsureNodeRuntime(ctx context.Context, nw run.NodeWork, repoPath string, spec harness.LaunchSpec, rt NodeRuntime) error {
|
|
108
|
+
a.Runs.runtimeMu.Lock()
|
|
109
|
+
defer a.Runs.runtimeMu.Unlock()
|
|
110
|
+
slog.Info("node entered",
|
|
111
|
+
"ticket", nw.Parent.Key, "runID", string(nw.RunID),
|
|
112
|
+
"repo", nw.Repo, "workflow", nw.Workflow,
|
|
113
|
+
"node", nw.Node, "nodeVisitID", string(nw.NodeVisitID),
|
|
114
|
+
"nodeType", string(spec.NodeType), "agent", spec.Agent)
|
|
115
|
+
revisit := rt.NodeVisitID != "" && rt.NodeVisitID != spec.NodeVisitID
|
|
116
|
+
current, err := a.Runs.nodeRuntimeVisitIsCurrent(ctx, nw.RunID, nw.Node, nw.NodeVisitID)
|
|
117
|
+
if err != nil {
|
|
118
|
+
return err
|
|
119
|
+
}
|
|
120
|
+
if !current {
|
|
121
|
+
return fmt.Errorf("node runtime %s/%s visit %s is stale", nw.RunID, nw.Node, nw.NodeVisitID)
|
|
122
|
+
}
|
|
123
|
+
currentRuntime, err := a.Runs.loadNodeRuntime(ctx, nw.RunID, nw.Node)
|
|
124
|
+
if err != nil {
|
|
125
|
+
return err
|
|
126
|
+
}
|
|
127
|
+
hadRuntime := currentRuntime.TerminalID != "" || currentRuntime.SessionID != ""
|
|
128
|
+
// IDs come from the guarded current row; the activity input's prior visit
|
|
129
|
+
// is used only to decide whether a live process needs rebinding.
|
|
130
|
+
rt.TerminalID = currentRuntime.TerminalID
|
|
131
|
+
rt.SessionID = currentRuntime.SessionID
|
|
132
|
+
freshSession := false
|
|
133
|
+
if rt.TerminalID != "" {
|
|
134
|
+
terminal := runner.Terminal{ID: rt.TerminalID, Title: spec.Title}
|
|
135
|
+
_, ok, inspectErr := a.Runner.InspectTerminal(ctx, terminal)
|
|
136
|
+
if inspectErr != nil {
|
|
137
|
+
return inspectErr
|
|
138
|
+
}
|
|
139
|
+
if ok {
|
|
140
|
+
// Same-visit retry/restart leaves the running turn untouched. A
|
|
141
|
+
// revisit sends only the new work prompt to the retained session.
|
|
142
|
+
if !revisit {
|
|
143
|
+
return a.Runs.replaceNodeRuntime(ctx, nw.RunID, nw.Node, nw.NodeVisitID,
|
|
144
|
+
rt.TerminalID, rt.SessionID, rt.SessionID)
|
|
145
|
+
}
|
|
146
|
+
prompt := followUpPrompt(nw.Mailbox.Key)
|
|
147
|
+
if spec.NudgePrompt != "" {
|
|
148
|
+
prompt += "\n\n" + spec.NudgePrompt
|
|
149
|
+
}
|
|
150
|
+
if err := a.Runner.SendTerminal(ctx, terminal, prompt); err == nil {
|
|
151
|
+
return a.Runs.replaceNodeRuntime(ctx, nw.RunID, nw.Node, nw.NodeVisitID,
|
|
152
|
+
rt.TerminalID, rt.SessionID, rt.SessionID)
|
|
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
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if rt.SessionID != "" && !freshSession {
|
|
164
|
+
spec.ResumeID = rt.SessionID
|
|
165
|
+
}
|
|
166
|
+
// Custom instructions belong to a node entry, not same-visit recovery.
|
|
167
|
+
if !hadRuntime || revisit {
|
|
168
|
+
spec.Prompt = appendPrompt(spec.Prompt, spec.NudgePrompt)
|
|
169
|
+
}
|
|
170
|
+
cmd, err := a.Harness.BuildCommand(spec)
|
|
171
|
+
if err != nil {
|
|
172
|
+
return err
|
|
173
|
+
}
|
|
174
|
+
rs := a.runSpec(nw.Work)
|
|
175
|
+
rs.RepoPath = repoPath
|
|
176
|
+
env, err := a.Runner.EnsureEnvironment(ctx, rs)
|
|
177
|
+
if err != nil {
|
|
178
|
+
return err
|
|
179
|
+
}
|
|
180
|
+
terminal, err := a.Runner.CreateTerminal(ctx, env, spec.Title, cmd)
|
|
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
|
+
}
|
|
194
|
+
if err != nil {
|
|
195
|
+
return err
|
|
196
|
+
}
|
|
197
|
+
if err := a.Runs.replaceNodeRuntime(ctx, nw.RunID, nw.Node, nw.NodeVisitID,
|
|
198
|
+
terminal.ID, rt.SessionID, sessionID); err != nil {
|
|
199
|
+
// The visit changed after terminal creation. Close the terminal whose
|
|
200
|
+
// binding was rejected so stale work cannot leak or report.
|
|
201
|
+
_ = a.Runner.CloseTerminal(ctx, terminal)
|
|
202
|
+
return err
|
|
203
|
+
}
|
|
204
|
+
return nil
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// CloseTerminals closes run-owned agent terminals, preserving the
|
|
208
|
+
// environment/workspace.
|
|
209
|
+
func (a *Activities) CloseTerminals(ctx context.Context, w run.Work, repoPath string) error {
|
|
210
|
+
spec := a.runSpec(w)
|
|
211
|
+
spec.RepoPath = repoPath
|
|
212
|
+
return a.Runner.CloseTerminals(ctx, spec)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
func (a *Activities) CheckpointNodeRuntime(ctx context.Context, nw run.NodeWork, repoPath string, policy run.RuntimePolicy) error {
|
|
216
|
+
a.Runs.runtimeMu.Lock()
|
|
217
|
+
defer a.Runs.runtimeMu.Unlock()
|
|
218
|
+
rt, err := a.Runs.loadNodeRuntime(ctx, nw.RunID, nw.Node)
|
|
219
|
+
if err != nil {
|
|
220
|
+
return err
|
|
221
|
+
}
|
|
222
|
+
if !policy.KeepTerminalsAlive && rt.TerminalID != "" {
|
|
223
|
+
if err := a.Runner.CloseTerminal(ctx, runner.Terminal{ID: rt.TerminalID, Title: nw.Parent.Key + ":" + nw.Node}); err != nil {
|
|
224
|
+
return err
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return a.Runs.clearNodeRuntime(ctx, nw.RunID, nw.Node,
|
|
228
|
+
!policy.KeepTerminalsAlive, !policy.KeepSessionsAlive)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
func (a *Activities) FinalizeNodeRuntimes(ctx context.Context, w run.Work, repoPath string, policy run.RuntimePolicy) error {
|
|
232
|
+
a.Runs.runtimeMu.Lock()
|
|
233
|
+
defer a.Runs.runtimeMu.Unlock()
|
|
234
|
+
runtimes, err := a.Runs.listNodeRuntimes(ctx, w.RunID)
|
|
235
|
+
if err != nil {
|
|
236
|
+
return err
|
|
237
|
+
}
|
|
238
|
+
if !policy.KeepTerminalsAlive {
|
|
239
|
+
for _, rt := range runtimes {
|
|
240
|
+
if rt.TerminalID == "" {
|
|
241
|
+
continue
|
|
242
|
+
}
|
|
243
|
+
if err := a.Runner.CloseTerminal(ctx, runner.Terminal{ID: rt.TerminalID, Title: w.Parent.Key + ":" + rt.Node}); err != nil {
|
|
244
|
+
return err
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
for _, rt := range runtimes {
|
|
249
|
+
if err := a.Runs.clearNodeRuntime(ctx, w.RunID, rt.Node,
|
|
250
|
+
!policy.KeepTerminalsAlive, !policy.KeepSessionsAlive); err != nil {
|
|
251
|
+
return err
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return nil
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// CleanupRun removes all runner-owned run resources at end.
|
|
258
|
+
func (a *Activities) CleanupRun(ctx context.Context, w run.Work, repoPath string) error {
|
|
259
|
+
spec := a.runSpec(w)
|
|
260
|
+
spec.RepoPath = repoPath
|
|
261
|
+
return a.Runner.CleanupRun(ctx, spec)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Comment writes a marked comment to the target on the run's repo.
|
|
265
|
+
//
|
|
266
|
+
// 9.3 transition logging: the interpreter uses markers of the form
|
|
267
|
+
// "<nodeVisitID>:summary" for the current node summary and
|
|
268
|
+
// "<nodeVisitID>:feedback" for the selected-next feedback, so this
|
|
269
|
+
// activity emits one info line per effect with the same ticket/runID/node
|
|
270
|
+
// attrs as the rest of the run. Cancellation markers and other comments
|
|
271
|
+
// still pass through silently (no transition effect).
|
|
272
|
+
func (a *Activities) Comment(ctx context.Context, repoName string, cw run.CommentWork) error {
|
|
273
|
+
sys, err := a.taskSystem(repoName)
|
|
274
|
+
if err != nil {
|
|
275
|
+
return err
|
|
276
|
+
}
|
|
277
|
+
if err := sys.Comment(ctx, cw.Item, cw.Body, cw.Marker); err != nil {
|
|
278
|
+
return err
|
|
279
|
+
}
|
|
280
|
+
// Log AFTER the write succeeds so the line is a true effect record.
|
|
281
|
+
// marker is "<nodeVisitID>:summary" | "<nodeVisitID>:feedback" |
|
|
282
|
+
// "<runID>:cancellation". Only the first two are transition effects.
|
|
283
|
+
visit, tag := splitMarker(cw.Marker)
|
|
284
|
+
if tag != "summary" && tag != "feedback" {
|
|
285
|
+
return nil
|
|
286
|
+
}
|
|
287
|
+
attrs := []any{
|
|
288
|
+
"ticket", cw.Item.Parent.Key, "repo", repoName,
|
|
289
|
+
"runID", string(cw.RunID), "nodeVisitID", visit,
|
|
290
|
+
}
|
|
291
|
+
// Workflow attribution comes from the projection; a missing read
|
|
292
|
+
// degrades to the always-known attrs above rather than failing the
|
|
293
|
+
// activity after the comment already landed.
|
|
294
|
+
if a.Runs != nil && a.Runs.DB != nil && cw.RunID != "" {
|
|
295
|
+
if r, err := a.Runs.get(ctx, cw.RunID); err == nil {
|
|
296
|
+
attrs = append(attrs, "workflow", r.Workflow)
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
var msg string
|
|
300
|
+
if tag == "summary" {
|
|
301
|
+
msg = "summary written"
|
|
302
|
+
} else {
|
|
303
|
+
msg = "feedback written"
|
|
304
|
+
if cw.Item.Mailbox != nil {
|
|
305
|
+
attrs = append(attrs, "node", cw.Item.Mailbox.Node, "mailbox", cw.Item.Mailbox.Key)
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
slog.Info(msg, attrs...)
|
|
309
|
+
return nil
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// splitMarker splits a "<prefix>:<tag>" marker; returns ("","") when there
|
|
313
|
+
// is no colon.
|
|
314
|
+
func splitMarker(m string) (string, string) {
|
|
315
|
+
i := strings.LastIndex(m, ":")
|
|
316
|
+
if i < 0 {
|
|
317
|
+
return "", ""
|
|
318
|
+
}
|
|
319
|
+
return m[:i], m[i+1:]
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// CompleteMailbox marks the current node mailbox complete.
|
|
323
|
+
//
|
|
324
|
+
// 9.3 transition logging: one info line per completed mailbox carrying the
|
|
325
|
+
// same ticket/runID/node attrs.
|
|
326
|
+
func (a *Activities) CompleteMailbox(ctx context.Context, w run.Work, mailbox task.Mailbox) error {
|
|
327
|
+
sys, err := a.taskSystem(w.Repo)
|
|
328
|
+
if err != nil {
|
|
329
|
+
return err
|
|
330
|
+
}
|
|
331
|
+
if err := sys.CompleteMailbox(ctx, mailbox); err != nil {
|
|
332
|
+
return err
|
|
333
|
+
}
|
|
334
|
+
slog.Info("mailbox completed",
|
|
335
|
+
"ticket", w.Parent.Key, "runID", string(w.RunID),
|
|
336
|
+
"repo", w.Repo, "workflow", w.Workflow,
|
|
337
|
+
"node", mailbox.Node, "mailbox", mailbox.Key)
|
|
338
|
+
return nil
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Projection activities: idempotent read-model updates.
|
|
342
|
+
|
|
343
|
+
func (a *Activities) ProjectionUpdateNode(ctx context.Context, id run.ID, state run.State, node string, visit run.NodeVisitID) error {
|
|
344
|
+
return a.Runs.updateNode(ctx, id, state, node, visit)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
func (a *Activities) ProjectionUpdateNodeRuntimeVisit(ctx context.Context, id run.ID, node string, visit run.NodeVisitID) error {
|
|
348
|
+
return a.Runs.updateNodeRuntimeVisit(ctx, id, node, visit)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
func (a *Activities) ProjectionRecordProcessedReport(ctx context.Context, id run.ID, visit run.NodeVisitID, reportID string) error {
|
|
352
|
+
return a.Runs.recordProcessedReport(ctx, id, visit, reportID)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
func (a *Activities) ProjectionUpdateState(ctx context.Context, id run.ID, state run.State, lastErr string, finished *time.Time) error {
|
|
356
|
+
if err := a.Runs.updateState(ctx, id, state, lastErr, finished); err != nil {
|
|
357
|
+
return err
|
|
358
|
+
}
|
|
359
|
+
// 9.3 run-lifecycle logging: one info line when a run reaches a
|
|
360
|
+
// terminal state. The projection row carries ticket/repo/workflow so
|
|
361
|
+
// the line is attributable without new plumbing through the workflow.
|
|
362
|
+
if state != run.StateCompleted && state != run.StateCanceled {
|
|
363
|
+
return nil
|
|
364
|
+
}
|
|
365
|
+
r, err := a.Runs.get(ctx, id)
|
|
366
|
+
if err != nil {
|
|
367
|
+
// Projection write succeeded; failure to re-read must not fail
|
|
368
|
+
// the activity. Skip the log line rather than retry forever.
|
|
369
|
+
return nil
|
|
370
|
+
}
|
|
371
|
+
attrs := []any{
|
|
372
|
+
"ticket", r.Ticket.Key, "runID", string(id),
|
|
373
|
+
"repo", r.Repo, "workflow", r.Workflow,
|
|
374
|
+
"state", string(state),
|
|
375
|
+
}
|
|
376
|
+
if r.LastError != "" {
|
|
377
|
+
attrs = append(attrs, "reason", r.LastError)
|
|
378
|
+
}
|
|
379
|
+
msg := "run completed"
|
|
380
|
+
if state == run.StateCanceled {
|
|
381
|
+
msg = "run canceled"
|
|
382
|
+
}
|
|
383
|
+
slog.Info(msg, attrs...)
|
|
384
|
+
return nil
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
func (a *Activities) ProjectionUpdateRetry(ctx context.Context, id run.ID, status *run.RetryStatus) error {
|
|
388
|
+
return a.Runs.updateRetry(ctx, id, status)
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// MailboxSpecForNode builds the mailbox description for a work node. The
|
|
392
|
+
// description defines the node work: node identity, parent, type, agent,
|
|
393
|
+
// description, and every legal route with its when explanation.
|
|
394
|
+
func MailboxSpecForNode(wf *workflow.Workflow, ticketKey, name string, n workflow.Node) task.MailboxSpec {
|
|
395
|
+
var b strings.Builder
|
|
396
|
+
fmt.Fprintf(&b, "Parent Jira ticket: %s\nNode: %s\nType: %s\nAgent: %s\n\nWork:\n%s\n\nRead this subtask's comments for feedback from previous nodes.",
|
|
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:
|
|
409
|
+
|
|
410
|
+
STATUS: success | failure
|
|
411
|
+
NEXT STEP: <one valid node name>
|
|
412
|
+
|
|
413
|
+
SUMMARY:
|
|
414
|
+
COMPLETED:
|
|
415
|
+
COMMITS:
|
|
416
|
+
NOT COMPLETED:
|
|
417
|
+
ISSUES DISCOVERED:
|
|
418
|
+
VERIFICATION:
|
|
419
|
+
NOTES:
|
|
420
|
+
|
|
421
|
+
FEEDBACK:
|
|
422
|
+
REASON FOR NEXT STEP:
|
|
423
|
+
REQUIRED ACTIONS:
|
|
424
|
+
RELEVANT CONTEXT:
|
|
425
|
+
EXPECTED RESULT:
|
|
426
|
+
|
|
427
|
+
Every field is required; use None for an intentionally empty section. COMMITS must contain the relevant commit IDs or None. NEXT STEP must name exactly one target listed below for your status. When NEXT STEP is end, every FEEDBACK field must be None.`)
|
|
428
|
+
writeRoutes := func(label string, routes []workflow.Route) {
|
|
429
|
+
if len(routes) == 0 {
|
|
430
|
+
return
|
|
431
|
+
}
|
|
432
|
+
fmt.Fprintf(&b, "\n\n%s:", label)
|
|
433
|
+
for _, r := range routes {
|
|
434
|
+
if r.When != "" {
|
|
435
|
+
fmt.Fprintf(&b, "\n- %s — when: %s", r.Target, r.When)
|
|
436
|
+
} else {
|
|
437
|
+
fmt.Fprintf(&b, "\n- %s", r.Target)
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
writeRoutes("On success", n.OnSuccess)
|
|
442
|
+
writeRoutes("On failure", n.OnFailure)
|
|
443
|
+
return task.MailboxSpec{
|
|
444
|
+
Node: name,
|
|
445
|
+
Title: ticketKey + ":" + name,
|
|
446
|
+
Description: b.String(),
|
|
447
|
+
TaskConfig: n.TaskConfig,
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// MailboxSpecs returns one spec per work node, sorted for determinism.
|
|
452
|
+
func MailboxSpecs(wf *workflow.Workflow, ticketKey string) []task.MailboxSpec {
|
|
453
|
+
names := make([]string, 0, len(wf.Nodes))
|
|
454
|
+
for name, n := range wf.Nodes {
|
|
455
|
+
if n.Type == workflow.NodeAgent || n.Type == workflow.NodeHITL {
|
|
456
|
+
names = append(names, name)
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
sort.Strings(names)
|
|
460
|
+
out := make([]task.MailboxSpec, 0, len(names))
|
|
461
|
+
for _, name := range names {
|
|
462
|
+
out = append(out, MailboxSpecForNode(wf, ticketKey, name, wf.Nodes[name]))
|
|
463
|
+
}
|
|
464
|
+
return out
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// BuildLaunchSpecPrompt points the agent to its parent and isolated mailbox.
|
|
468
|
+
func BuildLaunchSpecPrompt(ticketKey, mailboxKey string) string {
|
|
469
|
+
return fmt.Sprintf("Read parent Jira ticket %s for the original task context.\n\nYour Jira mailbox subtask is %s. Read only its description and comments for your node instructions and feedback.", ticketKey, mailboxKey)
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
func followUpPrompt(mailboxKey string) string {
|
|
473
|
+
return fmt.Sprintf("New feedback was added to the comments section of your mailbox subtask %s. Read it.", mailboxKey)
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
func appendPrompt(prompt, extra string) string {
|
|
477
|
+
if extra == "" {
|
|
478
|
+
return prompt
|
|
479
|
+
}
|
|
480
|
+
return prompt + "\n\n" + extra
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// mergeTaskConfig overlays node task config onto workflow task config using
|
|
484
|
+
// the shared deterministic merge (maps recursively, scalar/list replace).
|
|
485
|
+
func mergeTaskConfig(wfCfg, nodeCfg map[string]any) map[string]any {
|
|
486
|
+
if len(wfCfg) == 0 && len(nodeCfg) == 0 {
|
|
487
|
+
return nil
|
|
488
|
+
}
|
|
489
|
+
return config.Merge(wfCfg, nodeCfg)
|
|
490
|
+
}
|