relay-flow 0.2.4-alpha → 0.2.6-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 +29 -18
- package/cmd/relay-flow/backend_selection_test.go +149 -0
- package/cmd/relay-flow/beads_composition_test.go +3 -3
- package/cmd/relay-flow/main.go +94 -13
- package/cmd/relay-flow/scenario_test.go +19 -2
- package/cmd/relay-flow/serve.go +98 -19
- package/cmd/relay-flow/serve_recovery_test.go +100 -0
- package/cmd/relay-flow/temporal_init.go +170 -0
- package/cmd/relay-flow/temporal_init_test.go +217 -0
- package/cmd/relay-flow/temporal_report_test.go +733 -0
- package/examples/beads-workflow.yaml +3 -0
- package/examples/config-reference.yaml +7 -3
- package/examples/minimal-beads-task-workflow.yaml +2 -1
- package/examples/workflow-reference.yaml +2 -1
- package/go.mod +37 -16
- package/go.sum +129 -61
- package/internal/config/machine.go +33 -1
- package/internal/config/machine_test.go +76 -0
- package/internal/execution/goworkflows/activities.go +19 -0
- package/internal/execution/goworkflows/engine.go +13 -38
- package/internal/execution/goworkflows/engine_test.go +1 -1
- package/internal/execution/goworkflows/node_runtime_test.go +113 -4
- package/internal/execution/goworkflows/projection.go +47 -464
- package/internal/execution/projection/projection.go +867 -0
- package/internal/execution/projection/projection_test.go +347 -0
- package/internal/execution/temporal/activities.go +586 -0
- package/internal/execution/temporal/engine.go +384 -0
- package/internal/execution/temporal/engine_test.go +277 -0
- package/internal/execution/temporal/interpreter.go +736 -0
- package/internal/execution/temporal/operations.go +455 -0
- package/internal/execution/temporal/operations_test.go +101 -0
- package/internal/execution/temporal/recovery.go +194 -0
- package/internal/execution/temporal/recovery_runtime.go +41 -0
- package/internal/execution/temporal/recovery_test.go +102 -0
- package/internal/execution/temporal/snapshot_restart_test.go +72 -0
- package/internal/execution/temporal/spike_test.go +934 -0
- package/internal/execution/temporal/visibility_lag_test.go +415 -0
- package/internal/harness/harness.go +5 -0
- package/internal/harness/opencode/opencode.go +14 -3
- package/internal/harness/opencode/opencode_test.go +1 -1
- package/internal/harness/opencode/repo_setup.go +1 -1
- package/internal/harness/opencode/task_env_test.go +57 -0
- package/internal/harness/pi/pi.go +58 -47
- package/internal/harness/pi/pi_test.go +26 -10
- package/internal/harness/pi/prompt_test.go +30 -1
- package/internal/harness/pi/task_env_test.go +51 -0
- package/internal/harness/pi/validation_test.go +27 -51
- package/internal/repo/service.go +18 -8
- package/internal/runner/herdr/herdr.go +14 -0
- package/internal/runner/herdr/herdr_test.go +20 -0
- package/internal/runner/orca/orca.go +33 -0
- package/internal/runner/orca/orca_test.go +33 -4
- package/internal/runner/runner.go +8 -0
- package/internal/task/beads/agent_env_test.go +52 -0
- package/internal/task/beads/beads.go +87 -7
- package/internal/task/beads/beads_test.go +78 -9
- package/internal/task/beads/repo_composition_test.go +47 -3
- package/internal/task/factory.go +31 -2
- package/internal/task/task.go +10 -0
- package/package.json +1 -1
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
package temporal
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"os"
|
|
7
|
+
"path/filepath"
|
|
8
|
+
"sync"
|
|
9
|
+
"testing"
|
|
10
|
+
"time"
|
|
11
|
+
|
|
12
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
13
|
+
"github.com/rajpopat27/relay-flow/internal/execution/projection"
|
|
14
|
+
"github.com/rajpopat27/relay-flow/internal/harness"
|
|
15
|
+
"github.com/rajpopat27/relay-flow/internal/identity"
|
|
16
|
+
"github.com/rajpopat27/relay-flow/internal/repo"
|
|
17
|
+
"github.com/rajpopat27/relay-flow/internal/retry"
|
|
18
|
+
"github.com/rajpopat27/relay-flow/internal/run"
|
|
19
|
+
"github.com/rajpopat27/relay-flow/internal/runner"
|
|
20
|
+
"github.com/rajpopat27/relay-flow/internal/task"
|
|
21
|
+
domainworkflow "github.com/rajpopat27/relay-flow/internal/workflow"
|
|
22
|
+
enumspb "go.temporal.io/api/enums/v1"
|
|
23
|
+
"go.temporal.io/sdk/client"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
func TestTemporalRecoveryReconcilesActiveVisibilityLag(t *testing.T) {
|
|
27
|
+
if os.Getenv("RELAY_FLOW_TEMPORAL_LIVE") != "1" {
|
|
28
|
+
t.Skip("set RELAY_FLOW_TEMPORAL_LIVE=1 to run active Visibility-lag recovery against the local server")
|
|
29
|
+
}
|
|
30
|
+
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
|
31
|
+
defer cancel()
|
|
32
|
+
namespace := "relay-flow-visibility-lag-" + string(identity.NewNodeVisitID())[:12]
|
|
33
|
+
if err := ensureSpikeNamespace(ctx, "localhost:7233", namespace); err != nil {
|
|
34
|
+
t.Fatal(err)
|
|
35
|
+
}
|
|
36
|
+
root := t.TempDir()
|
|
37
|
+
database := filepath.Join(root, "state.db")
|
|
38
|
+
if err := projection.InitDatabaseWithIdentity(database, projection.ExecutorIdentity{
|
|
39
|
+
ExecutorPlugin: "temporal", TemporalAddress: "localhost:7233", TemporalNamespace: namespace,
|
|
40
|
+
}); err != nil {
|
|
41
|
+
t.Fatal(err)
|
|
42
|
+
}
|
|
43
|
+
sys := &lagTaskSystem{}
|
|
44
|
+
rnr := &lagRunner{}
|
|
45
|
+
hrn := &lagHarness{}
|
|
46
|
+
registry := repo.NewRegistry()
|
|
47
|
+
workflow := lagWorkflow()
|
|
48
|
+
registry.Replace(&repo.Repo{
|
|
49
|
+
Name: "repo", Path: "/repo", TaskSystem: sys,
|
|
50
|
+
Workflows: []repo.WorkflowBinding{{Workflow: &workflow}},
|
|
51
|
+
})
|
|
52
|
+
engine, err := New(database, Dependencies{
|
|
53
|
+
Repos: registry, Runner: rnr, Harness: hrn, TaskSystem: "lag-task",
|
|
54
|
+
TemporalAddress: "localhost:7233", TemporalNamespace: namespace,
|
|
55
|
+
Runtime: &run.RuntimePolicy{KeepTerminalsAlive: true, KeepSessionsAlive: true},
|
|
56
|
+
})
|
|
57
|
+
if err != nil {
|
|
58
|
+
t.Fatal(err)
|
|
59
|
+
}
|
|
60
|
+
if err := engine.Start(ctx); err != nil {
|
|
61
|
+
t.Fatal(err)
|
|
62
|
+
}
|
|
63
|
+
defer engine.Shutdown(context.Background())
|
|
64
|
+
start := run.Start{
|
|
65
|
+
ID: identity.NewRunID("repo", workflow.Name, "LAG-1"), Repo: "repo", RepoPath: "/repo",
|
|
66
|
+
Workflow: workflow, Ticket: task.TicketRef{ID: "LAG-1", Key: "LAG-1", Title: "Visibility lag"},
|
|
67
|
+
Runtime: run.RuntimePolicy{KeepTerminalsAlive: true, KeepSessionsAlive: true},
|
|
68
|
+
}
|
|
69
|
+
created, err := engine.EnsureRun(ctx, start)
|
|
70
|
+
if err != nil || !created {
|
|
71
|
+
t.Fatalf("EnsureRun = %v, %v", created, err)
|
|
72
|
+
}
|
|
73
|
+
waitForLagState(t, ctx, engine, start.ID, run.StateWaiting)
|
|
74
|
+
rnr.setLive(true)
|
|
75
|
+
description, err := engine.client.DescribeWorkflowExecution(ctx, string(start.ID), "")
|
|
76
|
+
if err != nil {
|
|
77
|
+
t.Fatal(err)
|
|
78
|
+
}
|
|
79
|
+
if description.WorkflowExecutionInfo == nil || description.WorkflowExecutionInfo.Execution == nil {
|
|
80
|
+
t.Fatal("Temporal describe returned no active execution")
|
|
81
|
+
}
|
|
82
|
+
runID := description.WorkflowExecutionInfo.Execution.RunId
|
|
83
|
+
beforeReconcile := countTemporalSignals(t, engine, start.ID, runID, reconcileSignalName)
|
|
84
|
+
created, err = engine.EnsureRun(ctx, start)
|
|
85
|
+
if err != nil || created {
|
|
86
|
+
t.Fatalf("duplicate EnsureRun = %v, %v", created, err)
|
|
87
|
+
}
|
|
88
|
+
if got := countTemporalSignals(t, engine, start.ID, runID, reconcileSignalName); got != beforeReconcile {
|
|
89
|
+
t.Fatalf("healthy terminal reconciliation emitted %d signals, want %d", got, beforeReconcile)
|
|
90
|
+
}
|
|
91
|
+
rnr.setLive(false)
|
|
92
|
+
created, err = engine.EnsureRun(ctx, start)
|
|
93
|
+
if err != nil || created {
|
|
94
|
+
t.Fatalf("missing-terminal EnsureRun = %v, %v", created, err)
|
|
95
|
+
}
|
|
96
|
+
deadline := time.Now().Add(10 * time.Second)
|
|
97
|
+
for countTemporalSignals(t, engine, start.ID, runID, reconcileSignalName) <= beforeReconcile {
|
|
98
|
+
if time.Now().After(deadline) {
|
|
99
|
+
t.Fatal("missing terminal reconciliation did not persist a reconcile signal")
|
|
100
|
+
}
|
|
101
|
+
time.Sleep(100 * time.Millisecond)
|
|
102
|
+
}
|
|
103
|
+
description, err = engine.client.DescribeWorkflowExecution(ctx, string(start.ID), "")
|
|
104
|
+
if err != nil || description.WorkflowExecutionInfo == nil || description.WorkflowExecutionInfo.Execution == nil || description.WorkflowExecutionInfo.Execution.RunId != runID {
|
|
105
|
+
t.Fatalf("duplicate EnsureRun changed execution: %#v, %v", description.WorkflowExecutionInfo, err)
|
|
106
|
+
}
|
|
107
|
+
workflowSnapshot, err := engine.workflowFromHistory(ctx, start.ID, runID)
|
|
108
|
+
if err != nil || workflowSnapshot.Name != workflow.Name || workflowSnapshot.Nodes["work"].Description != "work" {
|
|
109
|
+
t.Fatalf("Temporal workflow snapshot = %#v, %v", workflowSnapshot, err)
|
|
110
|
+
}
|
|
111
|
+
reportState, err := engine.queryReportState(ctx, start.ID, "not-yet-processed")
|
|
112
|
+
if err != nil || reportState.State != run.StateWaiting || reportState.CurrentNode != "work" || reportState.CurrentNodeVisitID == "" || reportState.Processed {
|
|
113
|
+
t.Fatalf("Temporal report-state snapshot = %#v, %v", reportState, err)
|
|
114
|
+
}
|
|
115
|
+
if _, err := engine.runs.DB.ExecContext(ctx, `DELETE FROM relay_runs WHERE id = ?`, string(start.ID)); err != nil {
|
|
116
|
+
t.Fatal(err)
|
|
117
|
+
}
|
|
118
|
+
created, err = engine.EnsureRun(ctx, start)
|
|
119
|
+
if err != nil || created {
|
|
120
|
+
t.Fatalf("EnsureRun after projection loss = %v, %v", created, err)
|
|
121
|
+
}
|
|
122
|
+
if got, err := engine.runs.Get(ctx, start.ID); err != nil || got.CurrentNode != "work" || got.CurrentNodeVisitID == "" {
|
|
123
|
+
t.Fatalf("reconciled missing projection = %+v, %v", got, err)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// A false visible entry models Visibility exposing only an older closed
|
|
127
|
+
// history for this Workflow ID. Recovery must still use exact DescribeWorkflowExecution
|
|
128
|
+
// and restore the currently running execution, never start a replacement.
|
|
129
|
+
if err := engine.reconcileClaimedParents(ctx, map[string]bool{string(start.ID): false}); err != nil {
|
|
130
|
+
t.Fatalf("reconcile visibility lag: %v", err)
|
|
131
|
+
}
|
|
132
|
+
got, err := engine.runs.Get(ctx, start.ID)
|
|
133
|
+
if err != nil {
|
|
134
|
+
t.Fatal(err)
|
|
135
|
+
}
|
|
136
|
+
if got.ID != start.ID || got.CurrentNode != "work" || got.State != run.StateWaiting || got.CurrentNodeVisitID == "" {
|
|
137
|
+
t.Fatalf("reconciled projection = %+v", got)
|
|
138
|
+
}
|
|
139
|
+
description, err = engine.client.DescribeWorkflowExecution(ctx, string(start.ID), "")
|
|
140
|
+
if err != nil {
|
|
141
|
+
t.Fatal(err)
|
|
142
|
+
}
|
|
143
|
+
if description.WorkflowExecutionInfo == nil || description.WorkflowExecutionInfo.Execution == nil || description.WorkflowExecutionInfo.Execution.RunId != runID {
|
|
144
|
+
t.Fatalf("visibility-lag reconciliation changed execution: %#v", description.WorkflowExecutionInfo)
|
|
145
|
+
}
|
|
146
|
+
if got := sys.pollCountValue(); got != 1 {
|
|
147
|
+
t.Fatalf("task-system Poll calls = %d, want one read-only reconciliation poll", got)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
func TestTemporalRecoveryReconcilesClosedHistoryToCurrentExecution(t *testing.T) {
|
|
152
|
+
if os.Getenv("RELAY_FLOW_TEMPORAL_LIVE") != "1" {
|
|
153
|
+
t.Skip("set RELAY_FLOW_TEMPORAL_LIVE=1 to run closed-history Visibility lag coverage")
|
|
154
|
+
}
|
|
155
|
+
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
|
156
|
+
defer cancel()
|
|
157
|
+
namespace := "relay-flow-closed-lag-" + string(identity.NewNodeVisitID())[:12]
|
|
158
|
+
if err := ensureSpikeNamespace(ctx, "localhost:7233", namespace); err != nil {
|
|
159
|
+
t.Fatal(err)
|
|
160
|
+
}
|
|
161
|
+
path := filepath.Join(t.TempDir(), "state.db")
|
|
162
|
+
if err := projection.InitDatabaseWithIdentity(path, projection.ExecutorIdentity{ExecutorPlugin: "temporal", TemporalAddress: "localhost:7233", TemporalNamespace: namespace}); err != nil {
|
|
163
|
+
t.Fatal(err)
|
|
164
|
+
}
|
|
165
|
+
sys := &lagTaskSystem{}
|
|
166
|
+
registry := repo.NewRegistry()
|
|
167
|
+
wf := lagWorkflow()
|
|
168
|
+
registry.Replace(&repo.Repo{Name: "repo", Path: "/repo", TaskSystem: sys, Workflows: []repo.WorkflowBinding{{Workflow: &wf}}})
|
|
169
|
+
engine, err := New(path, Dependencies{Repos: registry, Runner: &lagRunner{}, Harness: &lagHarness{}, TaskSystem: "lag-task", TemporalAddress: "localhost:7233", TemporalNamespace: namespace})
|
|
170
|
+
if err != nil {
|
|
171
|
+
t.Fatal(err)
|
|
172
|
+
}
|
|
173
|
+
if err := engine.Start(ctx); err != nil {
|
|
174
|
+
t.Fatal(err)
|
|
175
|
+
}
|
|
176
|
+
defer engine.Shutdown(context.Background())
|
|
177
|
+
start := run.Start{ID: identity.NewRunID("repo", wf.Name, "LAG-1"), Repo: "repo", RepoPath: "/repo", Workflow: wf, Ticket: task.TicketRef{ID: "LAG-1", Key: "LAG-1", Title: "Closed history lag"}}
|
|
178
|
+
badWorkflow := wf
|
|
179
|
+
badWorkflow.Nodes = make(map[string]domainworkflow.Node, len(wf.Nodes))
|
|
180
|
+
for name, node := range wf.Nodes {
|
|
181
|
+
badWorkflow.Nodes[name] = node
|
|
182
|
+
}
|
|
183
|
+
badWorkflow.Nodes["start"] = domainworkflow.Node{}
|
|
184
|
+
badStart := start
|
|
185
|
+
badStart.Workflow = badWorkflow
|
|
186
|
+
failed, err := engine.client.ExecuteWorkflow(ctx, client.StartWorkflowOptions{ID: string(start.ID), TaskQueue: TaskQueue, WorkflowIDReusePolicy: enumspb.WORKFLOW_ID_REUSE_POLICY_ALLOW_DUPLICATE_FAILED_ONLY}, TicketWorkflow, badStart)
|
|
187
|
+
if err != nil {
|
|
188
|
+
t.Fatal(err)
|
|
189
|
+
}
|
|
190
|
+
if err := failed.Get(ctx, nil); err == nil {
|
|
191
|
+
t.Fatal("malformed first execution unexpectedly succeeded")
|
|
192
|
+
}
|
|
193
|
+
current, err := engine.client.ExecuteWorkflow(ctx, client.StartWorkflowOptions{ID: string(start.ID), TaskQueue: TaskQueue, WorkflowIDReusePolicy: enumspb.WORKFLOW_ID_REUSE_POLICY_ALLOW_DUPLICATE_FAILED_ONLY}, TicketWorkflow, start)
|
|
194
|
+
if err != nil {
|
|
195
|
+
t.Fatal(err)
|
|
196
|
+
}
|
|
197
|
+
waitForLagState(t, ctx, engine, start.ID, run.StateWaiting)
|
|
198
|
+
if err := engine.reconcileClaimedParents(ctx, map[string]bool{string(start.ID): false}); err != nil {
|
|
199
|
+
t.Fatalf("reconcile closed-history lag: %v", err)
|
|
200
|
+
}
|
|
201
|
+
got, err := engine.runs.Get(ctx, start.ID)
|
|
202
|
+
if err != nil {
|
|
203
|
+
t.Fatal(err)
|
|
204
|
+
}
|
|
205
|
+
if got.State != run.StateWaiting || got.CurrentNode != "work" || got.CurrentNodeVisitID == "" {
|
|
206
|
+
t.Fatalf("reconciled current execution projection = %+v", got)
|
|
207
|
+
}
|
|
208
|
+
description, err := engine.client.DescribeWorkflowExecution(ctx, string(start.ID), "")
|
|
209
|
+
if err != nil || description.WorkflowExecutionInfo == nil || description.WorkflowExecutionInfo.Execution == nil || description.WorkflowExecutionInfo.Execution.RunId != current.GetRunID() {
|
|
210
|
+
t.Fatalf("reconciliation changed current execution: %#v, %v", description.WorkflowExecutionInfo, err)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
func TestTemporalConflictRetryBlocksThenResumes(t *testing.T) {
|
|
215
|
+
if os.Getenv("RELAY_FLOW_TEMPORAL_LIVE") != "1" {
|
|
216
|
+
t.Skip("set RELAY_FLOW_TEMPORAL_LIVE=1 to run Temporal conflict retry against the local server")
|
|
217
|
+
}
|
|
218
|
+
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
|
219
|
+
defer cancel()
|
|
220
|
+
namespace := "relay-flow-conflict-" + string(identity.NewNodeVisitID())[:12]
|
|
221
|
+
if err := ensureSpikeNamespace(ctx, "localhost:7233", namespace); err != nil {
|
|
222
|
+
t.Fatal(err)
|
|
223
|
+
}
|
|
224
|
+
path := filepath.Join(t.TempDir(), "state.db")
|
|
225
|
+
if err := projection.InitDatabaseWithIdentity(path, projection.ExecutorIdentity{ExecutorPlugin: "temporal", TemporalAddress: "localhost:7233", TemporalNamespace: namespace}); err != nil {
|
|
226
|
+
t.Fatal(err)
|
|
227
|
+
}
|
|
228
|
+
sys := &lagTaskSystem{applyFailures: 1}
|
|
229
|
+
registry := repo.NewRegistry()
|
|
230
|
+
wf := lagWorkflow()
|
|
231
|
+
registry.Replace(&repo.Repo{Name: "repo", Path: "/repo", TaskSystem: sys, Workflows: []repo.WorkflowBinding{{Workflow: &wf}}})
|
|
232
|
+
engine, err := New(path, Dependencies{Repos: registry, Runner: &lagRunner{}, Harness: &lagHarness{}, TaskSystem: "lag-task", TemporalAddress: "localhost:7233", TemporalNamespace: namespace})
|
|
233
|
+
if err != nil {
|
|
234
|
+
t.Fatal(err)
|
|
235
|
+
}
|
|
236
|
+
if err := engine.Start(ctx); err != nil {
|
|
237
|
+
t.Fatal(err)
|
|
238
|
+
}
|
|
239
|
+
defer engine.Shutdown(context.Background())
|
|
240
|
+
start := run.Start{ID: identity.NewRunID("repo", wf.Name, "CONFLICT-1"), Repo: "repo", RepoPath: "/repo", Workflow: wf, Ticket: task.TicketRef{ID: "CONFLICT-1", Key: "CONFLICT-1", Title: "Conflict"}}
|
|
241
|
+
if created, err := engine.EnsureRun(ctx, start); err != nil || !created {
|
|
242
|
+
t.Fatalf("EnsureRun = %v, %v", created, err)
|
|
243
|
+
}
|
|
244
|
+
deadline := time.Now().Add(20 * time.Second)
|
|
245
|
+
for {
|
|
246
|
+
got, err := engine.runs.Get(ctx, start.ID)
|
|
247
|
+
if err == nil && got.State == run.StateBlocked {
|
|
248
|
+
snapshot, queryErr := engine.queryRunState(ctx, start.ID, "")
|
|
249
|
+
if queryErr == nil && snapshot.Run.Retry != nil && snapshot.Run.State == run.StateBlocked {
|
|
250
|
+
break
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if time.Now().After(deadline) {
|
|
254
|
+
t.Fatalf("run never became blocked with retry metadata: %+v, %v", got, err)
|
|
255
|
+
}
|
|
256
|
+
time.Sleep(100 * time.Millisecond)
|
|
257
|
+
}
|
|
258
|
+
waitForLagState(t, ctx, engine, start.ID, run.StateWaiting)
|
|
259
|
+
got, err := engine.runs.Get(ctx, start.ID)
|
|
260
|
+
if err != nil {
|
|
261
|
+
t.Fatal(err)
|
|
262
|
+
}
|
|
263
|
+
if got.Retry != nil {
|
|
264
|
+
t.Fatalf("retry metadata remained after conflict recovery: %+v", got.Retry)
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
func lagWorkflow() domainworkflow.Workflow {
|
|
269
|
+
return domainworkflow.Workflow{
|
|
270
|
+
Name: "lagFlow", Repos: []string{"repo"},
|
|
271
|
+
Nodes: map[string]domainworkflow.Node{
|
|
272
|
+
"start": {OnSuccess: []domainworkflow.Route{{Target: "work"}}},
|
|
273
|
+
"work": {
|
|
274
|
+
Type: domainworkflow.NodeAgent, Agent: "agent", Description: "work",
|
|
275
|
+
OnSuccess: []domainworkflow.Route{{Target: "end"}},
|
|
276
|
+
OnFailure: []domainworkflow.Route{{Target: "end"}},
|
|
277
|
+
},
|
|
278
|
+
"end": {},
|
|
279
|
+
},
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
func countTemporalSignals(t *testing.T, engine *Engine, id run.ID, temporalRunID, signalName string) int {
|
|
284
|
+
t.Helper()
|
|
285
|
+
iterator := engine.client.GetWorkflowHistory(context.Background(), string(id), temporalRunID, false, enumspb.HISTORY_EVENT_FILTER_TYPE_ALL_EVENT)
|
|
286
|
+
count := 0
|
|
287
|
+
for iterator.HasNext() {
|
|
288
|
+
event, err := iterator.Next()
|
|
289
|
+
if err != nil {
|
|
290
|
+
t.Fatalf("read Temporal history for signal count: %v", err)
|
|
291
|
+
}
|
|
292
|
+
if event.GetEventType() == enumspb.EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED && event.GetWorkflowExecutionSignaledEventAttributes().GetSignalName() == signalName {
|
|
293
|
+
count++
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return count
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
func waitForLagState(t *testing.T, ctx context.Context, engine *Engine, id run.ID, state run.State) {
|
|
300
|
+
t.Helper()
|
|
301
|
+
for {
|
|
302
|
+
snapshot, err := engine.queryRunState(ctx, id, "")
|
|
303
|
+
if err == nil && snapshot.Run.State == state && snapshot.Run.CurrentNode != "" {
|
|
304
|
+
return
|
|
305
|
+
}
|
|
306
|
+
select {
|
|
307
|
+
case <-ctx.Done():
|
|
308
|
+
t.Fatalf("waiting for Temporal state %s: %v", state, ctx.Err())
|
|
309
|
+
case <-time.After(100 * time.Millisecond):
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
type lagTaskSystem struct {
|
|
315
|
+
mu sync.Mutex
|
|
316
|
+
polls int
|
|
317
|
+
applyFailures int
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
var _ task.System = (*lagTaskSystem)(nil)
|
|
321
|
+
|
|
322
|
+
func (s *lagTaskSystem) Poll(context.Context) ([]task.Ticket, error) {
|
|
323
|
+
s.mu.Lock()
|
|
324
|
+
s.polls++
|
|
325
|
+
s.mu.Unlock()
|
|
326
|
+
return []task.Ticket{{ID: "LAG-1", Key: "LAG-1", Title: "Visibility lag", WorkflowClaims: []string{"wf:lagFlow"}}}, nil
|
|
327
|
+
}
|
|
328
|
+
func (*lagTaskSystem) CompileFilter(config.RawValues) (func(task.Ticket) bool, error) {
|
|
329
|
+
return func(task.Ticket) bool { return true }, nil
|
|
330
|
+
}
|
|
331
|
+
func (*lagTaskSystem) Claim(context.Context, task.TicketRef, string) error { return nil }
|
|
332
|
+
func (*lagTaskSystem) ValidateConfig(context.Context, config.RawValues, map[string]config.RawValues) error {
|
|
333
|
+
return nil
|
|
334
|
+
}
|
|
335
|
+
func (*lagTaskSystem) RenderText(task.TextKind, task.TextData) (string, error) { return "", nil }
|
|
336
|
+
func (*lagTaskSystem) EnsureMailboxes(_ context.Context, _ task.TicketRef, _ string, specs []task.MailboxSpec) (map[string]task.Mailbox, error) {
|
|
337
|
+
out := make(map[string]task.Mailbox, len(specs))
|
|
338
|
+
for _, spec := range specs {
|
|
339
|
+
out[spec.Node] = task.Mailbox{ID: "mb-" + spec.Node, Key: spec.Title, Node: spec.Node}
|
|
340
|
+
}
|
|
341
|
+
return out, nil
|
|
342
|
+
}
|
|
343
|
+
func (s *lagTaskSystem) ApplyTaskConfig(context.Context, task.Target, config.RawValues) error {
|
|
344
|
+
s.mu.Lock()
|
|
345
|
+
defer s.mu.Unlock()
|
|
346
|
+
if s.applyFailures > 0 {
|
|
347
|
+
s.applyFailures--
|
|
348
|
+
return retry.ConflictError(errors.New("task status is not yet compatible"))
|
|
349
|
+
}
|
|
350
|
+
return nil
|
|
351
|
+
}
|
|
352
|
+
func (*lagTaskSystem) CompleteMailbox(context.Context, task.Mailbox) error { return nil }
|
|
353
|
+
func (*lagTaskSystem) HasComment(context.Context, task.Target, string) (bool, error) {
|
|
354
|
+
return false, nil
|
|
355
|
+
}
|
|
356
|
+
func (*lagTaskSystem) Comment(context.Context, task.Target, string, string) error { return nil }
|
|
357
|
+
func (*lagTaskSystem) ResetForRecovery(context.Context, task.TicketRef, []task.Mailbox, config.RawValues) error {
|
|
358
|
+
return nil
|
|
359
|
+
}
|
|
360
|
+
func (s *lagTaskSystem) pollCountValue() int { s.mu.Lock(); defer s.mu.Unlock(); return s.polls }
|
|
361
|
+
|
|
362
|
+
type lagRunner struct {
|
|
363
|
+
mu sync.Mutex
|
|
364
|
+
live bool
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
var _ runner.Runner = (*lagRunner)(nil)
|
|
368
|
+
|
|
369
|
+
func (*lagRunner) DiscoverRepos(context.Context) ([]runner.RepoCandidate, error) { return nil, nil }
|
|
370
|
+
func (*lagRunner) ValidateRepo(context.Context, string, string) error { return nil }
|
|
371
|
+
func (*lagRunner) EnsureEnvironment(context.Context, runner.RunSpec) (runner.Environment, error) {
|
|
372
|
+
return runner.Environment{ID: "env-lag", Path: "/repo"}, nil
|
|
373
|
+
}
|
|
374
|
+
func (*lagRunner) SetEnvironmentStatus(context.Context, runner.Environment, string) error { return nil }
|
|
375
|
+
func (r *lagRunner) setLive(live bool) {
|
|
376
|
+
r.mu.Lock()
|
|
377
|
+
r.live = live
|
|
378
|
+
r.mu.Unlock()
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
func (r *lagRunner) FindTerminal(_ context.Context, terminal runner.Terminal) (runner.Terminal, bool, error) {
|
|
382
|
+
r.mu.Lock()
|
|
383
|
+
live := r.live
|
|
384
|
+
r.mu.Unlock()
|
|
385
|
+
if live && terminal.ID != "" {
|
|
386
|
+
return terminal, true, nil
|
|
387
|
+
}
|
|
388
|
+
return runner.Terminal{}, false, nil
|
|
389
|
+
}
|
|
390
|
+
func (*lagRunner) CreateTerminal(_ context.Context, _ runner.Environment, title string, _ runner.Command) (runner.Terminal, error) {
|
|
391
|
+
return runner.Terminal{ID: "term-" + title, Title: title}, nil
|
|
392
|
+
}
|
|
393
|
+
func (*lagRunner) EnsureTerminal(_ context.Context, _ runner.Environment, _ runner.Terminal, title string, _ runner.Command) (runner.Terminal, error) {
|
|
394
|
+
return runner.Terminal{ID: "term-" + title, Title: title}, nil
|
|
395
|
+
}
|
|
396
|
+
func (*lagRunner) SendTerminal(context.Context, runner.Terminal, string) error { return nil }
|
|
397
|
+
func (*lagRunner) CloseTerminal(context.Context, runner.Terminal) error { return nil }
|
|
398
|
+
func (*lagRunner) CloseTerminals(context.Context, runner.RunSpec) error { return nil }
|
|
399
|
+
func (*lagRunner) CleanupRun(context.Context, runner.RunSpec) error { return nil }
|
|
400
|
+
|
|
401
|
+
type lagHarness struct{}
|
|
402
|
+
|
|
403
|
+
var _ harness.Harness = (*lagHarness)(nil)
|
|
404
|
+
|
|
405
|
+
func (*lagHarness) SetupRepo(context.Context, string) error { return nil }
|
|
406
|
+
func (*lagHarness) FindSession(context.Context, string, string) (harness.Session, bool, error) {
|
|
407
|
+
return harness.Session{}, false, nil
|
|
408
|
+
}
|
|
409
|
+
func (*lagHarness) ValidateAgent(context.Context, string, string) error { return nil }
|
|
410
|
+
func (*lagHarness) RenderPrompt(harness.PromptKind, harness.PromptData, string) (string, error) {
|
|
411
|
+
return "prompt", nil
|
|
412
|
+
}
|
|
413
|
+
func (*lagHarness) BuildCommand(harness.LaunchSpec) (runner.Command, error) {
|
|
414
|
+
return runner.Command{Executable: "agent"}, nil
|
|
415
|
+
}
|
|
@@ -56,6 +56,11 @@ type LaunchSpec struct {
|
|
|
56
56
|
PromptData PromptData
|
|
57
57
|
NextSteps []workflow.Route
|
|
58
58
|
ResumeID string
|
|
59
|
+
// TaskEnv is the task-system-owned workspace environment for the agent
|
|
60
|
+
// process, supplied by the repo's task adapter. The harness adds it to the
|
|
61
|
+
// launch command environment without interpreting it; relay-flow's own
|
|
62
|
+
// variables always win on conflict.
|
|
63
|
+
TaskEnv map[string]string
|
|
59
64
|
}
|
|
60
65
|
|
|
61
66
|
type Harness interface {
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// their stable <ticket>:<node> title, and builds the structured command
|
|
4
4
|
// the runner executes. The OpenCode runtime plugin (TypeScript, under
|
|
5
5
|
// plugin/) owns message parsing, title pinning, nudge-on-invalid for
|
|
6
|
-
// agent nodes
|
|
6
|
+
// agent nodes and for non-empty invalid HITL output, silence for missing
|
|
7
|
+
// or aborted HITL output, and exact-report JSON retry with the
|
|
7
8
|
// shared backoff constants mirrored in TypeScript.
|
|
8
9
|
package opencode
|
|
9
10
|
|
|
@@ -28,7 +29,9 @@ const (
|
|
|
28
29
|
defaultInitialPrompt = `Task system: {{taskSystem}}
|
|
29
30
|
Use the {{taskSystem}} tools to read the parent ticket {{ticket}}.
|
|
30
31
|
|
|
31
|
-
Your mailbox is {{mailbox}}. Read its description and comments for node instructions and feedback
|
|
32
|
+
Your mailbox is {{mailbox}}. Read its description and comments for node instructions and feedback.
|
|
33
|
+
|
|
34
|
+
Keep the summary brief, and make the feedback as detailed and actionable as possible for the next agent.`
|
|
32
35
|
defaultFeedbackPrompt = `New feedback was added to the comments section of your mailbox subtask {{mailbox}}. Read it.`
|
|
33
36
|
defaultHITLPrompt = `Return the complete report directly. Relay-flow will show a native TUI approval dialog after the report is valid. Do not use OpenCode's Question tool for relay-flow approval.`
|
|
34
37
|
)
|
|
@@ -184,7 +187,13 @@ func (h *Harness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error)
|
|
|
184
187
|
if err != nil {
|
|
185
188
|
return runner.Command{}, err
|
|
186
189
|
}
|
|
187
|
-
env := map[string]string{
|
|
190
|
+
env := map[string]string{}
|
|
191
|
+
// Task-system workspace values are applied first so relay-flow's own
|
|
192
|
+
// variables always win on conflict.
|
|
193
|
+
for key, value := range spec.TaskEnv {
|
|
194
|
+
env[key] = value
|
|
195
|
+
}
|
|
196
|
+
for key, value := range map[string]string{
|
|
188
197
|
"RELAY_FLOW_HOME": root,
|
|
189
198
|
"RELAY_FLOW_RUN_ID": string(spec.RunID),
|
|
190
199
|
"RELAY_FLOW_WORKFLOW": spec.Workflow,
|
|
@@ -194,6 +203,8 @@ func (h *Harness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error)
|
|
|
194
203
|
"RELAY_FLOW_NODE_TYPE": string(spec.NodeType),
|
|
195
204
|
"RELAY_FLOW_NUDGE_PROMPT": spec.NudgePrompt,
|
|
196
205
|
"RELAY_FLOW_NEXT_STEPS_JSON": string(nextSteps),
|
|
206
|
+
} {
|
|
207
|
+
env[key] = value
|
|
197
208
|
}
|
|
198
209
|
args := []string{}
|
|
199
210
|
if spec.ResumeID != "" {
|
|
@@ -14,7 +14,7 @@ import (
|
|
|
14
14
|
"github.com/rajpopat27/relay-flow/internal/workflow"
|
|
15
15
|
)
|
|
16
16
|
|
|
17
|
-
const configuredPlugin = "relay-flow-plugin@0.2.
|
|
17
|
+
const configuredPlugin = "relay-flow-plugin@0.2.6-alpha"
|
|
18
18
|
|
|
19
19
|
func TestBuildCommandArgv(t *testing.T) {
|
|
20
20
|
t.Setenv("RELAY_FLOW_HOME", "/var/lib/relay-flow-test")
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
package opencode_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"testing"
|
|
5
|
+
|
|
6
|
+
"github.com/rajpopat27/relay-flow/internal/harness"
|
|
7
|
+
"github.com/rajpopat27/relay-flow/internal/harness/opencode"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
// TestBuildCommandCarriesTaskSystemEnvironment asserts the task-system
|
|
11
|
+
// workspace environment reaches the runner command so agent task commands
|
|
12
|
+
// address the same workspace as relay-flow, and that relay-flow's own
|
|
13
|
+
// variables cannot be overwritten by the task system.
|
|
14
|
+
func TestBuildCommandCarriesTaskSystemEnvironment(t *testing.T) {
|
|
15
|
+
t.Setenv("RELAY_FLOW_HOME", "/var/lib/relay-flow-test")
|
|
16
|
+
|
|
17
|
+
cmd, err := opencode.New().BuildCommand(harness.LaunchSpec{
|
|
18
|
+
Agent: "build",
|
|
19
|
+
Node: "implement",
|
|
20
|
+
Prompt: "implement the ticket",
|
|
21
|
+
TaskEnv: map[string]string{
|
|
22
|
+
"BEADS_DIR": "/var/lib/beads/payments/.beads",
|
|
23
|
+
"BEADS_DB": "",
|
|
24
|
+
"BD_DB": "",
|
|
25
|
+
"RELAY_FLOW_NODE": "hijacked",
|
|
26
|
+
},
|
|
27
|
+
})
|
|
28
|
+
if err != nil {
|
|
29
|
+
t.Fatalf("BuildCommand: %v", err)
|
|
30
|
+
}
|
|
31
|
+
if cmd.Env["BEADS_DIR"] != "/var/lib/beads/payments/.beads" {
|
|
32
|
+
t.Fatalf("BEADS_DIR = %q", cmd.Env["BEADS_DIR"])
|
|
33
|
+
}
|
|
34
|
+
for _, key := range []string{"BEADS_DB", "BD_DB"} {
|
|
35
|
+
value, ok := cmd.Env[key]
|
|
36
|
+
if !ok || value != "" {
|
|
37
|
+
t.Fatalf("%s = %q, present=%v; want empty selector", key, value, ok)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if cmd.Env["RELAY_FLOW_NODE"] != "implement" {
|
|
41
|
+
t.Fatalf("RELAY_FLOW_NODE = %q, want implement", cmd.Env["RELAY_FLOW_NODE"])
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// TestBuildCommandWithoutTaskEnvKeepsRelayVariablesOnly asserts an adapter
|
|
46
|
+
// that exposes no workspace environment adds nothing to the command.
|
|
47
|
+
func TestBuildCommandWithoutTaskEnvKeepsRelayVariablesOnly(t *testing.T) {
|
|
48
|
+
t.Setenv("RELAY_FLOW_HOME", "/var/lib/relay-flow-test")
|
|
49
|
+
|
|
50
|
+
cmd, err := opencode.New().BuildCommand(harness.LaunchSpec{Agent: "build", Prompt: "work"})
|
|
51
|
+
if err != nil {
|
|
52
|
+
t.Fatalf("BuildCommand: %v", err)
|
|
53
|
+
}
|
|
54
|
+
if len(cmd.Env) != 9 {
|
|
55
|
+
t.Fatalf("Env = %#v, want only the nine relay-flow variables", cmd.Env)
|
|
56
|
+
}
|
|
57
|
+
}
|