relay-flow 0.2.4-alpha → 0.2.5-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 +20 -15
- package/cmd/relay-flow/backend_selection_test.go +149 -0
- 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/config-reference.yaml +2 -2
- 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/engine.go +13 -38
- 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 +567 -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/opencode/opencode.go +3 -1
- package/internal/harness/opencode/opencode_test.go +1 -1
- package/internal/harness/opencode/repo_setup.go +1 -1
- package/internal/harness/pi/pi.go +49 -46
- package/internal/harness/pi/pi_test.go +26 -10
- package/internal/harness/pi/prompt_test.go +30 -1
- package/internal/harness/pi/validation_test.go +27 -51
- 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/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
|
+
}
|
|
@@ -28,7 +28,9 @@ const (
|
|
|
28
28
|
defaultInitialPrompt = `Task system: {{taskSystem}}
|
|
29
29
|
Use the {{taskSystem}} tools to read the parent ticket {{ticket}}.
|
|
30
30
|
|
|
31
|
-
Your mailbox is {{mailbox}}. Read its description and comments for node instructions and feedback
|
|
31
|
+
Your mailbox is {{mailbox}}. Read its description and comments for node instructions and feedback.
|
|
32
|
+
|
|
33
|
+
Keep the summary brief, and make the feedback as detailed and actionable as possible for the next agent.`
|
|
32
34
|
defaultFeedbackPrompt = `New feedback was added to the comments section of your mailbox subtask {{mailbox}}. Read it.`
|
|
33
35
|
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
36
|
)
|
|
@@ -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.5-alpha"
|
|
18
18
|
|
|
19
19
|
func TestBuildCommandArgv(t *testing.T) {
|
|
20
20
|
t.Setenv("RELAY_FLOW_HOME", "/var/lib/relay-flow-test")
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Package pi is the built-in launch-time Harness adapter for the Pi coding
|
|
2
2
|
// agent. Pi supplies one built-in coding agent, represented by the logical
|
|
3
|
-
// relay-flow agent name "default", plus
|
|
4
|
-
// .pi/
|
|
3
|
+
// relay-flow agent name "default", plus project-owned prompt templates under
|
|
4
|
+
// .pi/prompts. The runtime extension is installed by the user and owns report
|
|
5
5
|
// parsing and delivery.
|
|
6
6
|
package pi
|
|
7
7
|
|
|
@@ -24,7 +24,9 @@ const (
|
|
|
24
24
|
defaultInitialPrompt = `Task system: {{taskSystem}}
|
|
25
25
|
Use the {{taskSystem}} tools to read the parent ticket {{ticket}}.
|
|
26
26
|
|
|
27
|
-
Your mailbox is {{mailbox}}. Read its description and comments for node instructions and feedback
|
|
27
|
+
Your mailbox is {{mailbox}}. Read its description and comments for node instructions and feedback.
|
|
28
|
+
|
|
29
|
+
Keep the summary brief, and make the feedback as detailed and actionable as possible for the next agent.`
|
|
28
30
|
defaultFeedbackPrompt = `New feedback was added to the comments section of your mailbox subtask {{mailbox}}. Read it.`
|
|
29
31
|
)
|
|
30
32
|
|
|
@@ -97,11 +99,11 @@ func New(cfg ...Config) *Harness {
|
|
|
97
99
|
// in each repository.
|
|
98
100
|
func (*Harness) SetupRepo(context.Context, string) error { return nil }
|
|
99
101
|
|
|
100
|
-
// ValidateAgent
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
func (*Harness) ValidateAgent(_ context.Context,
|
|
104
|
-
if
|
|
102
|
+
// ValidateAgent checks the Pi executable and the prompt-template command name.
|
|
103
|
+
// Pi owns prompt-template discovery; relay-flow does not inspect or register
|
|
104
|
+
// the user's project-owned .pi/prompts files.
|
|
105
|
+
func (*Harness) ValidateAgent(_ context.Context, _ string, agent string) error {
|
|
106
|
+
if err := validateAgentName(agent); err != nil {
|
|
105
107
|
return err
|
|
106
108
|
}
|
|
107
109
|
if _, err := exec.LookPath("pi"); err != nil {
|
|
@@ -110,39 +112,33 @@ func (*Harness) ValidateAgent(_ context.Context, repoPath, agent string) error {
|
|
|
110
112
|
return nil
|
|
111
113
|
}
|
|
112
114
|
|
|
113
|
-
func
|
|
115
|
+
func validateAgentName(agent string) error {
|
|
114
116
|
if agent == "default" {
|
|
115
|
-
return
|
|
117
|
+
return nil
|
|
116
118
|
}
|
|
117
119
|
if strings.TrimSpace(agent) == "" || strings.TrimSpace(agent) != agent || strings.ContainsAny(agent, `/\\`) || agent == "." || agent == ".." {
|
|
118
|
-
return
|
|
119
|
-
}
|
|
120
|
-
if repoPath == "" {
|
|
121
|
-
return "", fmt.Errorf("pi: role %q requires a repository path", agent)
|
|
122
|
-
}
|
|
123
|
-
root, err := filepath.Abs(repoPath)
|
|
124
|
-
if err != nil {
|
|
125
|
-
return "", fmt.Errorf("pi: resolve repository path for role %q: %w", agent, err)
|
|
126
|
-
}
|
|
127
|
-
path := filepath.Join(root, ".pi", "roles", agent+".md")
|
|
128
|
-
info, err := os.Stat(path)
|
|
129
|
-
if err != nil {
|
|
130
|
-
if os.IsNotExist(err) {
|
|
131
|
-
return "", fmt.Errorf("pi: role %q is unavailable; expected %s", agent, path)
|
|
132
|
-
}
|
|
133
|
-
return "", fmt.Errorf("pi: inspect role %q: %w", agent, err)
|
|
120
|
+
return fmt.Errorf("pi: invalid prompt template %q", agent)
|
|
134
121
|
}
|
|
135
|
-
|
|
136
|
-
|
|
122
|
+
return nil
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
func promptTemplatePath(agent string) string {
|
|
126
|
+
return filepath.Join(".pi", "prompts", agent+".md")
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
func applyPromptTemplate(agent, prompt string) string {
|
|
130
|
+
if agent == "" || agent == "default" {
|
|
131
|
+
return prompt
|
|
137
132
|
}
|
|
138
|
-
|
|
139
|
-
if
|
|
140
|
-
|
|
133
|
+
prefix := "/" + agent
|
|
134
|
+
if prompt == prefix || strings.HasPrefix(prompt, prefix+" ") ||
|
|
135
|
+
strings.HasPrefix(prompt, prefix+"\t") || strings.HasPrefix(prompt, prefix+"\n") {
|
|
136
|
+
return prompt
|
|
141
137
|
}
|
|
142
|
-
if
|
|
143
|
-
return
|
|
138
|
+
if prompt == "" {
|
|
139
|
+
return prefix
|
|
144
140
|
}
|
|
145
|
-
return
|
|
141
|
+
return prefix + " " + prompt
|
|
146
142
|
}
|
|
147
143
|
|
|
148
144
|
// FindSession is intentionally discovery-free. Normal execution resumes only
|
|
@@ -152,8 +148,10 @@ func (*Harness) FindSession(context.Context, string, string) (harness.Session, b
|
|
|
152
148
|
}
|
|
153
149
|
|
|
154
150
|
// RenderPrompt renders the selected initial or feedback template and the
|
|
155
|
-
// node's nudge template.
|
|
156
|
-
//
|
|
151
|
+
// node's nudge template. Named Pi agents are native prompt-template commands,
|
|
152
|
+
// so the complete rendered text is supplied as the command arguments. HITL
|
|
153
|
+
// approval is not encoded in the prompt; the Pi extension asks for approval
|
|
154
|
+
// through ctx.ui.select.
|
|
157
155
|
func (h *Harness) RenderPrompt(kind harness.PromptKind, data harness.PromptData, nudgeTemplate string) (string, error) {
|
|
158
156
|
var tmpl string
|
|
159
157
|
switch kind {
|
|
@@ -164,18 +162,23 @@ func (h *Harness) RenderPrompt(kind harness.PromptKind, data harness.PromptData,
|
|
|
164
162
|
default:
|
|
165
163
|
return "", fmt.Errorf("pi: unknown prompt kind %q", kind)
|
|
166
164
|
}
|
|
167
|
-
|
|
165
|
+
if data.Agent != "" {
|
|
166
|
+
if err := validateAgentName(data.Agent); err != nil {
|
|
167
|
+
return "", err
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
prompt := appendPrompt(renderTemplate(tmpl, data), renderTemplate(nudgeTemplate, data))
|
|
171
|
+
return applyPromptTemplate(data.Agent, prompt), nil
|
|
168
172
|
}
|
|
169
173
|
|
|
170
174
|
// BuildCommand returns the interactive Pi invocation. The runner supplies a
|
|
171
175
|
// PTY for Pi's stdin/stdout; the rendered prompt is the final positional argv
|
|
172
|
-
// value. A
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
// resume option.
|
|
176
|
+
// value. A named workflow agent adds Pi's --prompt-template option for the
|
|
177
|
+
// project-owned .pi/prompts/<agent>.md file and invokes it with its native
|
|
178
|
+
// slash-command syntax. Pi 0.84.1 rejects a bare -- terminator, so none is
|
|
179
|
+
// included. A non-empty ResumeID selects Pi's exact session-id resume option.
|
|
176
180
|
func (*Harness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error) {
|
|
177
|
-
|
|
178
|
-
if err != nil {
|
|
181
|
+
if err := validateAgentName(spec.Agent); err != nil {
|
|
179
182
|
return runner.Command{}, err
|
|
180
183
|
}
|
|
181
184
|
nextSteps, err := json.Marshal(spec.NextSteps)
|
|
@@ -198,13 +201,13 @@ func (*Harness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error) {
|
|
|
198
201
|
"RELAY_FLOW_NEXT_STEPS_JSON": string(nextSteps),
|
|
199
202
|
}
|
|
200
203
|
args := []string{"--name", spec.Title}
|
|
201
|
-
if
|
|
202
|
-
args = append(args, "--
|
|
204
|
+
if spec.Agent != "default" {
|
|
205
|
+
args = append(args, "--prompt-template", promptTemplatePath(spec.Agent))
|
|
203
206
|
}
|
|
204
207
|
if spec.ResumeID != "" {
|
|
205
208
|
args = append(args, "--session-id", spec.ResumeID)
|
|
206
209
|
}
|
|
207
|
-
args = append(args, spec.Prompt)
|
|
210
|
+
args = append(args, applyPromptTemplate(spec.Agent, spec.Prompt))
|
|
208
211
|
return runner.Command{
|
|
209
212
|
Executable: "pi",
|
|
210
213
|
Args: args,
|