relay-flow 0.3.7-alpha → 0.3.8-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 +5 -5
- package/cmd/relay-flow/observability_test.go +28 -0
- package/cmd/relay-flow/render.go +20 -4
- package/cmd/relay-flow/scenario_test.go +15 -13
- package/cmd/relay-flow/serve.go +116 -103
- package/internal/execution/goworkflows/activities.go +6 -0
- package/internal/execution/temporal/activities.go +6 -0
- package/internal/execution/temporal/recovery.go +4 -0
- package/internal/harness/opencode/opencode_test.go +1 -1
- package/internal/harness/opencode/repo_setup.go +1 -1
- package/internal/recover/recover.go +4 -0
- package/internal/repo/binding_test.go +92 -0
- package/internal/repo/poller.go +3 -0
- package/internal/repo/repo.go +82 -6
- package/internal/run/manager.go +41 -0
- package/internal/run/run_manager_test.go +56 -0
- package/internal/server/observability.go +1 -1
- package/internal/task/beads/beads.go +42 -4
- package/internal/task/beads/beads_test.go +15 -0
- package/internal/task/factory.go +41 -2
- package/internal/task/jira/jira.go +52 -28
- package/internal/workflow/service.go +53 -0
- package/internal/workflow/store.go +413 -19
- package/internal/workflow/store_test.go +235 -0
- package/internal/workflow/workflow.go +71 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -24,13 +24,13 @@ same `relay-flow-plugin` package with host-specific entrypoints.
|
|
|
24
24
|
**OpenCode**
|
|
25
25
|
|
|
26
26
|
```sh
|
|
27
|
-
opencode plugin relay-flow-plugin@0.3.
|
|
27
|
+
opencode plugin relay-flow-plugin@0.3.8-alpha
|
|
28
28
|
```
|
|
29
29
|
|
|
30
30
|
**Pi**
|
|
31
31
|
|
|
32
32
|
```sh
|
|
33
|
-
pi install npm:relay-flow-plugin@0.3.
|
|
33
|
+
pi install npm:relay-flow-plugin@0.3.8-alpha
|
|
34
34
|
```
|
|
35
35
|
|
|
36
36
|
Pi loads the package's `pi.ts` extension from its manifest. Do not add
|
|
@@ -160,7 +160,7 @@ OpenCode plugin configuration uses both entrypoints. The server entrypoint is li
|
|
|
160
160
|
```json
|
|
161
161
|
{
|
|
162
162
|
"$schema": "https://opencode.ai/config.json",
|
|
163
|
-
"plugin": ["relay-flow-plugin@0.3.
|
|
163
|
+
"plugin": ["relay-flow-plugin@0.3.8-alpha"]
|
|
164
164
|
}
|
|
165
165
|
```
|
|
166
166
|
|
|
@@ -169,7 +169,7 @@ The native HITL approval entrypoint is listed in `.opencode/tui.json`:
|
|
|
169
169
|
```json
|
|
170
170
|
{
|
|
171
171
|
"$schema": "https://opencode.ai/tui.json",
|
|
172
|
-
"plugin": ["relay-flow-plugin@0.3.
|
|
172
|
+
"plugin": ["relay-flow-plugin@0.3.8-alpha"]
|
|
173
173
|
}
|
|
174
174
|
```
|
|
175
175
|
|
|
@@ -185,7 +185,7 @@ Pi plugin: install the same published package manually in Pi's global package
|
|
|
185
185
|
settings before starting a Pi harness session:
|
|
186
186
|
|
|
187
187
|
```sh
|
|
188
|
-
pi install npm:relay-flow-plugin@0.3.
|
|
188
|
+
pi install npm:relay-flow-plugin@0.3.8-alpha
|
|
189
189
|
```
|
|
190
190
|
|
|
191
191
|
Relay-flow does not install or configure the package automatically. Pi resolves
|
|
@@ -276,6 +276,34 @@ func TestBlockedActiveRunCountsAsActiveAndFailed(t *testing.T) {
|
|
|
276
276
|
}
|
|
277
277
|
}
|
|
278
278
|
|
|
279
|
+
func TestOutdatedWorkflowUsesDistinctNonBlockingMarker(t *testing.T) {
|
|
280
|
+
wf := &workflow.Workflow{Name: "editedFlow", Status: workflow.HealthOutdated, StatusReason: "hash mismatch", RepairCommand: "relay-flow workflow submit --file /tmp/editedFlow.yaml"}
|
|
281
|
+
for _, options := range []renderOptions{{ASCII: true, Width: 120}, {ASCII: false, Width: 120}} {
|
|
282
|
+
var out bytes.Buffer
|
|
283
|
+
renderWorkflowSummaries(&out, []server.WorkflowSummary{{Workflow: wf}}, options)
|
|
284
|
+
text := out.String()
|
|
285
|
+
if !strings.Contains(text, "hash mismatch") {
|
|
286
|
+
t.Fatalf("outdated workflow reason missing from output %q", text)
|
|
287
|
+
}
|
|
288
|
+
if options.ASCII && !strings.Contains(text, "[!]") {
|
|
289
|
+
t.Fatalf("ASCII outdated marker missing from output %q", text)
|
|
290
|
+
}
|
|
291
|
+
if !options.ASCII && !strings.Contains(text, "!") {
|
|
292
|
+
t.Fatalf("non-ASCII outdated marker missing from output %q", text)
|
|
293
|
+
}
|
|
294
|
+
for _, line := range strings.Split(text, "\n") {
|
|
295
|
+
if strings.Contains(line, "editedFlow") && strings.Contains(line, "✗") {
|
|
296
|
+
t.Fatalf("outdated workflow rendered as generic failure: %q", text)
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
var detail bytes.Buffer
|
|
301
|
+
renderWorkflowDetail(&detail, server.WorkflowDetail{Workflow: wf}, renderOptions{ASCII: true, Width: 120})
|
|
302
|
+
if !strings.Contains(detail.String(), "REPAIR") || !strings.Contains(detail.String(), wf.RepairCommand) {
|
|
303
|
+
t.Fatalf("outdated workflow repair command missing from detail: %q", detail.String())
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
279
307
|
func TestNarrowWorkflowDetailStaysWithinTerminalWidth(t *testing.T) {
|
|
280
308
|
wf := &workflow.Workflow{Name: "a-very-long-workflow-name", Repos: []string{"a-very-long-repository-name"}, Nodes: map[string]workflow.Node{workflow.StartNode: {}, workflow.EndNode: {}}}
|
|
281
309
|
var out bytes.Buffer
|
package/cmd/relay-flow/render.go
CHANGED
|
@@ -106,7 +106,7 @@ func statusMark(status string, ascii bool) string {
|
|
|
106
106
|
return "[x]"
|
|
107
107
|
case "running", "waiting", "active":
|
|
108
108
|
return "[>]"
|
|
109
|
-
case "failed", "blocked":
|
|
109
|
+
case "failed", "blocked", "unverified", "outdated":
|
|
110
110
|
return "[!]"
|
|
111
111
|
case "canceled":
|
|
112
112
|
return "[-]"
|
|
@@ -119,8 +119,10 @@ func statusMark(status string, ascii bool) string {
|
|
|
119
119
|
return "✓"
|
|
120
120
|
case "running", "waiting", "active":
|
|
121
121
|
return "⟳"
|
|
122
|
-
case "failed", "blocked":
|
|
122
|
+
case "failed", "blocked", "unverified":
|
|
123
123
|
return "✗"
|
|
124
|
+
case "outdated":
|
|
125
|
+
return "!"
|
|
124
126
|
case "canceled":
|
|
125
127
|
return "−"
|
|
126
128
|
default:
|
|
@@ -401,6 +403,10 @@ func renderWorkflowSummariesBody(w io.Writer, summaries []server.WorkflowSummary
|
|
|
401
403
|
}
|
|
402
404
|
state = markForRun(*summary.LatestRun, options.ASCII)
|
|
403
405
|
}
|
|
406
|
+
if summary.Status == workflow.HealthBlocked || summary.Status == workflow.HealthUnverified || summary.Status == workflow.HealthOutdated {
|
|
407
|
+
state = statusMark(string(summary.Status), options.ASCII)
|
|
408
|
+
last = valueOrDash(summary.StatusReason)
|
|
409
|
+
}
|
|
404
410
|
active += summary.ActiveRuns
|
|
405
411
|
fmt.Fprintf(tw, "%s\t%s\t%s\t%d\t%d\t%s\n", state, name, valueOrDash(repos), summary.NodeCount, summary.ActiveRuns, last)
|
|
406
412
|
}
|
|
@@ -477,8 +483,18 @@ func renderWorkflowDetailBody(w io.Writer, detail server.WorkflowDetail, options
|
|
|
477
483
|
}
|
|
478
484
|
fmt.Fprintf(w, "WORKFLOW %s\n", wf.Name)
|
|
479
485
|
fmt.Fprintln(w, strings.Repeat("=", 80))
|
|
480
|
-
|
|
481
|
-
|
|
486
|
+
status := string(wf.Status)
|
|
487
|
+
if status == "" || wf.Status == workflow.HealthHealthy {
|
|
488
|
+
status = "healthy"
|
|
489
|
+
}
|
|
490
|
+
statusMarkValue := statusMark(status, options.ASCII)
|
|
491
|
+
fmt.Fprintf(w, "STATUS %s %s\n", statusMarkValue, strings.ToUpper(status))
|
|
492
|
+
if wf.StatusReason != "" {
|
|
493
|
+
fmt.Fprintf(w, "REASON %s\n", wf.StatusReason)
|
|
494
|
+
}
|
|
495
|
+
if wf.RepairCommand != "" {
|
|
496
|
+
fmt.Fprintf(w, "REPAIR %s\n", wf.RepairCommand)
|
|
497
|
+
}
|
|
482
498
|
fmt.Fprintf(w, "REPOSITORIES %s\n", valueOrDash(strings.Join(wf.Repos, ", ")))
|
|
483
499
|
fmt.Fprintf(w, "NODES %d\n", len(wf.Nodes))
|
|
484
500
|
cleanup := "disabled"
|
|
@@ -39,6 +39,17 @@ var (
|
|
|
39
39
|
)
|
|
40
40
|
|
|
41
41
|
func init() {
|
|
42
|
+
newScenarioTask := func(context.Context, task.RepoSpec) (task.System, error) {
|
|
43
|
+
scenarioFactoryMu.Lock()
|
|
44
|
+
defer scenarioFactoryMu.Unlock()
|
|
45
|
+
if scenarioFactorySystem == nil {
|
|
46
|
+
return nil, errors.New("scenario task system not configured")
|
|
47
|
+
}
|
|
48
|
+
if fake, ok := scenarioFactorySystem.(*scenarioTaskSystem); ok {
|
|
49
|
+
fake.log.add("factory:task:" + scenarioTaskPlugin)
|
|
50
|
+
}
|
|
51
|
+
return scenarioFactorySystem, nil
|
|
52
|
+
}
|
|
42
53
|
task.Register(scenarioTaskPlugin, task.Factory{
|
|
43
54
|
RequiredRepoKeys: func() []string { return nil },
|
|
44
55
|
DefaultConfig: func() config.RawValues {
|
|
@@ -47,17 +58,8 @@ func init() {
|
|
|
47
58
|
TaskScopeKey: func(config.RawValues, config.RawValues) (string, error) {
|
|
48
59
|
return "scenario-scope", nil
|
|
49
60
|
},
|
|
50
|
-
New:
|
|
51
|
-
|
|
52
|
-
defer scenarioFactoryMu.Unlock()
|
|
53
|
-
if scenarioFactorySystem == nil {
|
|
54
|
-
return nil, errors.New("scenario task system not configured")
|
|
55
|
-
}
|
|
56
|
-
if fake, ok := scenarioFactorySystem.(*scenarioTaskSystem); ok {
|
|
57
|
-
fake.log.add("factory:task:" + scenarioTaskPlugin)
|
|
58
|
-
}
|
|
59
|
-
return scenarioFactorySystem, nil
|
|
60
|
-
},
|
|
61
|
+
New: newScenarioTask,
|
|
62
|
+
NewLocal: newScenarioTask,
|
|
61
63
|
})
|
|
62
64
|
runner.Register(scenarioRunnerPlugin, func(config.RawValues) (runner.Runner, error) {
|
|
63
65
|
scenarioFactoryMu.Lock()
|
|
@@ -213,8 +215,8 @@ func TestCompositionRootSelectsAlternatePluginsForDurableRun(t *testing.T) {
|
|
|
213
215
|
"factory:task:" + scenarioTaskPlugin,
|
|
214
216
|
"factory:runner:" + scenarioRunnerPlugin,
|
|
215
217
|
"factory:harness:" + scenarioHarnessPlugin,
|
|
216
|
-
|
|
217
|
-
|
|
218
|
+
// Workflow task, runner, and harness preflight is submission-only;
|
|
219
|
+
// startup must not repeat it for an accepted definition.
|
|
218
220
|
"harness-agent-validated:implementer",
|
|
219
221
|
"harness-launched:" + scenarioTicket + ":implement",
|
|
220
222
|
"terminal-created:" + scenarioTicket + ":implement",
|
package/cmd/relay-flow/serve.go
CHANGED
|
@@ -50,8 +50,8 @@ import (
|
|
|
50
50
|
//
|
|
51
51
|
// flock → load machine config → select task/runner/harness factories →
|
|
52
52
|
// construct shared runner/harness → load repos + one task.System per repo →
|
|
53
|
-
// load workflow files
|
|
54
|
-
//
|
|
53
|
+
// load workflow files with integrity/local checks → bind trusted
|
|
54
|
+
// workflows+matchers to repos → open go-workflows
|
|
55
55
|
// SQLite engine and start its workers → construct the Run Manager →
|
|
56
56
|
// start the Repo Poller group → start the Unix-socket server.
|
|
57
57
|
func serveRoot(ctx context.Context, p paths.Paths, recover bool) error {
|
|
@@ -102,61 +102,82 @@ func serveRoot(ctx context.Context, p paths.Paths, recover bool) error {
|
|
|
102
102
|
// Load registered repos and one task.System per repo. The registry is
|
|
103
103
|
// shared with repo.Service (constructed below) so the engine, pollers,
|
|
104
104
|
// and server handlers observe the same in-memory set.
|
|
105
|
+
if err := task.ValidateLocal(cfg.TaskPlugin); err != nil {
|
|
106
|
+
return fmt.Errorf("task plugin %q cannot support normal startup: %w", cfg.TaskPlugin, err)
|
|
107
|
+
}
|
|
105
108
|
repoReg := repo.NewRegistry()
|
|
106
109
|
for name, rc := range cfg.Repos {
|
|
107
|
-
|
|
110
|
+
spec := task.RepoSpec{
|
|
108
111
|
Name: name,
|
|
109
112
|
Path: rc.Path,
|
|
110
113
|
RootConfig: cfg.TaskConfig,
|
|
111
114
|
RepoConfig: rc.TaskConfig,
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
+
}
|
|
116
|
+
ts, taskErr := task.NewLocal(ctx, cfg.TaskPlugin, spec)
|
|
117
|
+
if taskErr != nil {
|
|
118
|
+
// A repo-local adapter construction failure must not prevent the
|
|
119
|
+
// management API or unrelated repositories from starting. Its
|
|
120
|
+
// workflows are isolated during binding and can be repaired by
|
|
121
|
+
// resubmission after the task-system issue is fixed.
|
|
122
|
+
slog.Warn("repository task system unavailable during startup",
|
|
123
|
+
"repo", name, "error", taskErr)
|
|
115
124
|
}
|
|
116
125
|
repoReg.Replace(&repo.Repo{
|
|
117
|
-
Name:
|
|
118
|
-
Path:
|
|
119
|
-
TaskConfig:
|
|
120
|
-
TaskSystem:
|
|
126
|
+
Name: name,
|
|
127
|
+
Path: rc.Path,
|
|
128
|
+
TaskConfig: rc.TaskConfig,
|
|
129
|
+
TaskSystem: ts,
|
|
130
|
+
TaskSystemError: taskErr,
|
|
121
131
|
})
|
|
122
132
|
}
|
|
123
133
|
|
|
124
|
-
// Load workflow files.
|
|
134
|
+
// Load workflow files independently. Startup verifies the accepted
|
|
135
|
+
// integrity hash and performs only cheap local parsing/structure checks;
|
|
136
|
+
// malformed, legacy, or changed definitions are retained for inspection
|
|
137
|
+
// but cannot publish routes.
|
|
125
138
|
store := &workflow.Store{Dir: p.Workflows}
|
|
126
|
-
|
|
139
|
+
records, err := store.LoadAllRecords()
|
|
127
140
|
if err != nil {
|
|
128
141
|
return fmt.Errorf("load workflows: %w", err)
|
|
129
142
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
if err := wf.Validate(); err != nil {
|
|
136
|
-
return fmt.Errorf("workflow %q: %w", wf.Name, err)
|
|
143
|
+
workflowList := make([]*workflow.Workflow, 0, len(records))
|
|
144
|
+
for _, record := range records {
|
|
145
|
+
wf := record.Workflow
|
|
146
|
+
if wf == nil {
|
|
147
|
+
continue
|
|
137
148
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
}
|
|
146
|
-
for _, repoName := range wf.Repos {
|
|
147
|
-
rp, ok := repoReg.Get(repoName)
|
|
148
|
-
if !ok {
|
|
149
|
-
return fmt.Errorf("workflow %q references unregistered repo %q", wf.Name, repoName)
|
|
149
|
+
if wf.IsRoutable() {
|
|
150
|
+
for _, repoName := range wf.Repos {
|
|
151
|
+
if _, ok := repoReg.Get(repoName); !ok {
|
|
152
|
+
reason := fmt.Sprintf("workflow %q references unregistered repo %q", wf.Name, repoName)
|
|
153
|
+
wf.MarkBlocked(reason, wf.RepairCommand)
|
|
154
|
+
break
|
|
155
|
+
}
|
|
150
156
|
}
|
|
151
|
-
|
|
152
|
-
|
|
157
|
+
}
|
|
158
|
+
workflowList = append(workflowList, wf)
|
|
159
|
+
if !wf.IsRoutable() {
|
|
160
|
+
if wf.RepairCommand == "" {
|
|
161
|
+
slog.Warn("workflow isolated during startup",
|
|
162
|
+
"workflow", wf.Name, "status", wf.Status,
|
|
163
|
+
"reason", wf.StatusReason)
|
|
164
|
+
} else {
|
|
165
|
+
slog.Warn("workflow isolated during startup",
|
|
166
|
+
"workflow", wf.Name, "status", wf.Status,
|
|
167
|
+
"reason", wf.StatusReason, "repair", wf.RepairCommand)
|
|
153
168
|
}
|
|
154
169
|
}
|
|
155
170
|
}
|
|
156
171
|
|
|
157
|
-
//
|
|
158
|
-
|
|
159
|
-
|
|
172
|
+
// Rebuild derived repository bindings per workflow. Invalid definitions
|
|
173
|
+
// are isolated while healthy workflows continue routing.
|
|
174
|
+
for _, issue := range repoReg.BindWorkflowsIsolated(workflowList) {
|
|
175
|
+
if issue.Workflow == nil {
|
|
176
|
+
continue
|
|
177
|
+
}
|
|
178
|
+
slog.Warn("workflow binding unavailable",
|
|
179
|
+
"workflow", issue.Workflow.Name, "status", issue.Workflow.Status,
|
|
180
|
+
"reason", issue.Error, "repair", issue.Workflow.RepairCommand)
|
|
160
181
|
}
|
|
161
182
|
|
|
162
183
|
// 5.5 normal-start refusal: serve REQUIRES an initialized database
|
|
@@ -253,18 +274,6 @@ func serveRoot(ctx context.Context, p paths.Paths, recover bool) error {
|
|
|
253
274
|
return fmt.Errorf("open %s engine: %w", cfg.ExecutorPlugin, err)
|
|
254
275
|
}
|
|
255
276
|
|
|
256
|
-
// 5.2 fail-fast preflight: before workers/pollers start, validate
|
|
257
|
-
// task-system, runner, and harness credentials/permissions/connectivity,
|
|
258
|
-
// every registered repo, and every configured agent. Known permanent
|
|
259
|
-
// errors abort startup; runtime failures of existing runs retry forever
|
|
260
|
-
// (design.md decision 16).
|
|
261
|
-
if err := preflight(ctx, repoReg, rnr, hrn, workflowList); err != nil {
|
|
262
|
-
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
263
|
-
_ = engine.Shutdown(shutdownCtx)
|
|
264
|
-
cancel()
|
|
265
|
-
return fmt.Errorf("startup validation: %w", err)
|
|
266
|
-
}
|
|
267
|
-
|
|
268
277
|
// Start engine workers and the one-shot startup retention sweep
|
|
269
278
|
// (3.25; runs once here, no background ticker).
|
|
270
279
|
if err := engine.Start(ctx); err != nil {
|
|
@@ -290,6 +299,7 @@ func serveRoot(ctx context.Context, p paths.Paths, recover bool) error {
|
|
|
290
299
|
runManager.Workflows = wfSvc.Registry()
|
|
291
300
|
wfSvc.Gate = lifecycleGate
|
|
292
301
|
wfSvc.ValidateTaskConfig = workflowConfigValidator(repoReg)
|
|
302
|
+
wfSvc.ValidateSubmission = workflowSubmissionValidator(repoReg, rnr, hrn)
|
|
293
303
|
// Submit/Remove must also rebuild repo bindings under the gate (spec
|
|
294
304
|
// 3.34: bindings rebuilt on submit/remove/startup). Wired as a plain
|
|
295
305
|
// callback — no dispatcher.
|
|
@@ -483,6 +493,13 @@ func workflowConfigValidator(repoReg *repo.Registry) func(context.Context, *work
|
|
|
483
493
|
if !ok {
|
|
484
494
|
return fmt.Errorf("workflow %q references unregistered repo %q", wf.Name, repoName)
|
|
485
495
|
}
|
|
496
|
+
if rp.TaskSystem == nil {
|
|
497
|
+
reason := rp.TaskSystemError
|
|
498
|
+
if reason == nil {
|
|
499
|
+
reason = fmt.Errorf("no local task-system constructor")
|
|
500
|
+
}
|
|
501
|
+
return fmt.Errorf("workflow %q repo %q: task system unavailable: %w", wf.Name, repoName, reason)
|
|
502
|
+
}
|
|
486
503
|
if err := rp.TaskSystem.ValidateConfig(ctx, wf.TaskConfig, nodeCfgs); err != nil {
|
|
487
504
|
return fmt.Errorf("workflow %q repo %q: %w", wf.Name, repoName, err)
|
|
488
505
|
}
|
|
@@ -491,6 +508,57 @@ func workflowConfigValidator(repoReg *repo.Registry) func(context.Context, *work
|
|
|
491
508
|
}
|
|
492
509
|
}
|
|
493
510
|
|
|
511
|
+
// workflowSubmissionValidator runs checks that are specific to a candidate
|
|
512
|
+
// workflow and therefore belong at submission time, not on every restart.
|
|
513
|
+
// Task config validation remains behind workflowConfigValidator; this callback
|
|
514
|
+
// covers runner availability, task-system connectivity, filter compilation,
|
|
515
|
+
// and every agent used by the candidate across its referenced repositories.
|
|
516
|
+
func workflowSubmissionValidator(repoReg *repo.Registry, rnr runner.Runner, hrn harness.Harness) func(context.Context, *workflow.Workflow) error {
|
|
517
|
+
return func(ctx context.Context, wf *workflow.Workflow) error {
|
|
518
|
+
type agentProbe struct {
|
|
519
|
+
repoPath string
|
|
520
|
+
agent string
|
|
521
|
+
}
|
|
522
|
+
seen := map[agentProbe]bool{}
|
|
523
|
+
for _, repoName := range wf.Repos {
|
|
524
|
+
rp, ok := repoReg.Get(repoName)
|
|
525
|
+
if !ok {
|
|
526
|
+
return fmt.Errorf("workflow %q references unregistered repo %q", wf.Name, repoName)
|
|
527
|
+
}
|
|
528
|
+
if rp.TaskSystem == nil {
|
|
529
|
+
reason := rp.TaskSystemError
|
|
530
|
+
if reason == nil {
|
|
531
|
+
reason = fmt.Errorf("no local task-system constructor")
|
|
532
|
+
}
|
|
533
|
+
return fmt.Errorf("workflow %q repo %q: task system unavailable: %w", wf.Name, repoName, reason)
|
|
534
|
+
}
|
|
535
|
+
if err := rnr.ValidateRepo(ctx, rp.Name, rp.Path); err != nil {
|
|
536
|
+
return fmt.Errorf("workflow %q repo %q runner: %w", wf.Name, repoName, err)
|
|
537
|
+
}
|
|
538
|
+
if _, err := rp.TaskSystem.Poll(ctx); err != nil {
|
|
539
|
+
return fmt.Errorf("workflow %q repo %q task system: %w", wf.Name, repoName, err)
|
|
540
|
+
}
|
|
541
|
+
if _, err := rp.TaskSystem.CompileFilter(wf.TaskConfig); err != nil {
|
|
542
|
+
return fmt.Errorf("workflow %q repo %q: compile filter: %w", wf.Name, repoName, err)
|
|
543
|
+
}
|
|
544
|
+
for nodeName, node := range wf.Nodes {
|
|
545
|
+
if (node.Type != workflow.NodeAgent && node.Type != workflow.NodeHITL) || node.Agent == "" {
|
|
546
|
+
continue
|
|
547
|
+
}
|
|
548
|
+
probe := agentProbe{repoPath: rp.Path, agent: node.Agent}
|
|
549
|
+
if seen[probe] {
|
|
550
|
+
continue
|
|
551
|
+
}
|
|
552
|
+
seen[probe] = true
|
|
553
|
+
if err := hrn.ValidateAgent(ctx, rp.Path, node.Agent); err != nil {
|
|
554
|
+
return fmt.Errorf("workflow %q node %q repo %q harness: %w", wf.Name, nodeName, repoName, err)
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
return nil
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
494
562
|
// repoExists adapts *repo.Registry to workflow.RepoLookup (Exists).
|
|
495
563
|
type repoExists struct{ reg *repo.Registry }
|
|
496
564
|
|
|
@@ -680,60 +748,5 @@ func handleBatch(runManager *runsvc.RunManager) repo.BatchHandler {
|
|
|
680
748
|
}
|
|
681
749
|
}
|
|
682
750
|
|
|
683
|
-
// preflight is the 5.2 fail-fast startup validation. It runs after the
|
|
684
|
-
// engine is open and BEFORE workers/pollers start. Permanent errors abort
|
|
685
|
-
// startup; runtime errors of existing runs retry forever (different rule).
|
|
686
|
-
//
|
|
687
|
-
// Probes are no-side-effect reads on each adapter boundary:
|
|
688
|
-
// - runner: ValidateRepo per registered repo (Orca connectivity + repo
|
|
689
|
-
// presence at the configured path).
|
|
690
|
-
// - task system: Poll per repo (credentials/permissions/connectivity;
|
|
691
|
-
// returns active parents only, mutates nothing).
|
|
692
|
-
// - harness: ValidateAgent per (repo, workflow) agent node (configured
|
|
693
|
-
// agent must exist for that repo).
|
|
694
|
-
//
|
|
695
|
-
// Repos with no workflows still validate runner + task connectivity; agent
|
|
696
|
-
// validation runs once per distinct (repoPath, agent) pair to avoid
|
|
697
|
-
// redundant CLI calls when several workflows share an agent on one repo.
|
|
698
|
-
func preflight(ctx context.Context, repoReg *repo.Registry, rnr runner.Runner, hrn harness.Harness, workflows []*workflow.Workflow) error {
|
|
699
|
-
for _, rp := range repoReg.List() {
|
|
700
|
-
if err := rnr.ValidateRepo(ctx, rp.Name, rp.Path); err != nil {
|
|
701
|
-
return fmt.Errorf("repo %q runner: %w", rp.Name, err)
|
|
702
|
-
}
|
|
703
|
-
if _, err := rp.TaskSystem.Poll(ctx); err != nil {
|
|
704
|
-
return fmt.Errorf("repo %q task system: %w", rp.Name, err)
|
|
705
|
-
}
|
|
706
|
-
}
|
|
707
|
-
type agentProbe struct {
|
|
708
|
-
repoPath string
|
|
709
|
-
agent string
|
|
710
|
-
}
|
|
711
|
-
seen := map[agentProbe]bool{}
|
|
712
|
-
for _, wf := range workflows {
|
|
713
|
-
for nodeName, n := range wf.Nodes {
|
|
714
|
-
// Validate EVERY configured agent (agent and HITL work nodes
|
|
715
|
-
// both declare one); skip start/end which never carry Agent.
|
|
716
|
-
if (n.Type != workflow.NodeAgent && n.Type != workflow.NodeHITL) || n.Agent == "" {
|
|
717
|
-
continue
|
|
718
|
-
}
|
|
719
|
-
for _, repoName := range wf.Repos {
|
|
720
|
-
rp, ok := repoReg.Get(repoName)
|
|
721
|
-
if !ok {
|
|
722
|
-
return fmt.Errorf("workflow %q references unregistered repo %q", wf.Name, repoName)
|
|
723
|
-
}
|
|
724
|
-
key := agentProbe{repoPath: rp.Path, agent: n.Agent}
|
|
725
|
-
if seen[key] {
|
|
726
|
-
continue
|
|
727
|
-
}
|
|
728
|
-
seen[key] = true
|
|
729
|
-
if err := hrn.ValidateAgent(ctx, rp.Path, n.Agent); err != nil {
|
|
730
|
-
return fmt.Errorf("workflow %q node %q repo %q: %w", wf.Name, nodeName, repoName, err)
|
|
731
|
-
}
|
|
732
|
-
}
|
|
733
|
-
}
|
|
734
|
-
}
|
|
735
|
-
return nil
|
|
736
|
-
}
|
|
737
|
-
|
|
738
751
|
// The recover composition lives in internal/recover (recoverpkg.FromTaskSystem)
|
|
739
752
|
// so it is importable by both serve and the recover test — see task 6.3.
|
|
@@ -32,6 +32,12 @@ func (a *Activities) taskSystem(repoName string) (task.System, error) {
|
|
|
32
32
|
if !ok {
|
|
33
33
|
return nil, fmt.Errorf("repo %q is not registered", repoName)
|
|
34
34
|
}
|
|
35
|
+
if rp.TaskSystem == nil {
|
|
36
|
+
if rp.TaskSystemError != nil {
|
|
37
|
+
return nil, fmt.Errorf("repo %q task system unavailable: %w", repoName, rp.TaskSystemError)
|
|
38
|
+
}
|
|
39
|
+
return nil, fmt.Errorf("repo %q task system unavailable", repoName)
|
|
40
|
+
}
|
|
35
41
|
return rp.TaskSystem, nil
|
|
36
42
|
}
|
|
37
43
|
|
|
@@ -37,6 +37,12 @@ func (a *Activities) taskSystem(repoName string) (task.System, error) {
|
|
|
37
37
|
if !ok {
|
|
38
38
|
return nil, fmt.Errorf("repo %q is not registered", repoName)
|
|
39
39
|
}
|
|
40
|
+
if rp.TaskSystem == nil {
|
|
41
|
+
if rp.TaskSystemError != nil {
|
|
42
|
+
return nil, fmt.Errorf("repo %q task system unavailable: %w", repoName, rp.TaskSystemError)
|
|
43
|
+
}
|
|
44
|
+
return nil, fmt.Errorf("repo %q task system unavailable", repoName)
|
|
45
|
+
}
|
|
40
46
|
return rp.TaskSystem, nil
|
|
41
47
|
}
|
|
42
48
|
|
|
@@ -141,6 +141,10 @@ func listWorkflowExecutions(ctx context.Context, list func(context.Context, *wor
|
|
|
141
141
|
// ID is described and restored only when Temporal already owns that execution.
|
|
142
142
|
func (e *Engine) reconcileClaimedParents(ctx context.Context, visible map[string]bool) error {
|
|
143
143
|
for _, rp := range e.deps.Repos.List() {
|
|
144
|
+
if rp.TaskSystem == nil {
|
|
145
|
+
slog.Warn("Temporal recovery skipping repository with unavailable task system", "repo", rp.Name, "error", rp.TaskSystemError)
|
|
146
|
+
continue
|
|
147
|
+
}
|
|
144
148
|
tickets, err := rp.TaskSystem.Poll(ctx)
|
|
145
149
|
if err != nil {
|
|
146
150
|
return fmt.Errorf("poll repo %q during Temporal recovery: %w", rp.Name, err)
|
|
@@ -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.3.
|
|
17
|
+
const configuredPlugin = "relay-flow-plugin@0.3.8-alpha"
|
|
18
18
|
|
|
19
19
|
func TestBuildCommandArgv(t *testing.T) {
|
|
20
20
|
t.Setenv("RELAY_FLOW_HOME", "/var/lib/relay-flow-test")
|
|
@@ -46,6 +46,10 @@ type MailboxSpecFor func(task.System, run.Work, *workflow.Workflow) ([]task.Mail
|
|
|
46
46
|
// never automatic; database loss is never inferred.
|
|
47
47
|
func FromTaskSystem(ctx context.Context, repoReg *repo.Registry, rnr runner.Runner, runManager *run.RunManager, specsFor MailboxSpecFor) error {
|
|
48
48
|
for _, rp := range repoReg.List() {
|
|
49
|
+
if rp.TaskSystem == nil {
|
|
50
|
+
slog.Warn("recover: skip repository with unavailable task system", "repo", rp.Name, "error", rp.TaskSystemError)
|
|
51
|
+
continue
|
|
52
|
+
}
|
|
49
53
|
tickets, err := rp.TaskSystem.Poll(ctx)
|
|
50
54
|
if err != nil {
|
|
51
55
|
return fmt.Errorf("repo %q poll: %w", rp.Name, err)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
package repo
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"errors"
|
|
5
|
+
"strings"
|
|
6
|
+
"testing"
|
|
7
|
+
|
|
8
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
9
|
+
"github.com/rajpopat27/relay-flow/internal/task"
|
|
10
|
+
"github.com/rajpopat27/relay-flow/internal/workflow"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
type bindingTaskSystem struct {
|
|
14
|
+
task.System
|
|
15
|
+
compileErr map[string]error
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
func (s bindingTaskSystem) CompileFilter(values config.RawValues) (func(task.Ticket) bool, error) {
|
|
19
|
+
if name, ok := values["name"].(string); ok {
|
|
20
|
+
if err := s.compileErr[name]; err != nil {
|
|
21
|
+
return nil, err
|
|
22
|
+
}
|
|
23
|
+
return func(ticket task.Ticket) bool { return ticket.Key == name }, nil
|
|
24
|
+
}
|
|
25
|
+
return func(task.Ticket) bool { return true }, nil
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
func TestBindWorkflowsIsolatedPublishesHealthyWorkflows(t *testing.T) {
|
|
29
|
+
system := bindingTaskSystem{compileErr: map[string]error{"bad": errors.New("invalid filter")}}
|
|
30
|
+
registered := &Repo{Name: "payments", TaskSystem: system}
|
|
31
|
+
registry := NewRegistry()
|
|
32
|
+
registry.Replace(registered)
|
|
33
|
+
|
|
34
|
+
bad := &workflow.Workflow{Name: "badFlow", Repos: []string{"payments"}, Status: workflow.HealthHealthy, TaskConfig: config.RawValues{"name": "bad"}}
|
|
35
|
+
good := &workflow.Workflow{Name: "goodFlow", Repos: []string{"payments"}, Status: workflow.HealthHealthy, TaskConfig: config.RawValues{"name": "good"}}
|
|
36
|
+
issues := registry.BindWorkflowsIsolated([]*workflow.Workflow{bad, good})
|
|
37
|
+
if len(issues) != 1 || issues[0].Workflow != bad {
|
|
38
|
+
t.Fatalf("binding issues = %#v, want only badFlow", issues)
|
|
39
|
+
}
|
|
40
|
+
if bad.Status != workflow.HealthBlocked || bad.StatusReason == "" {
|
|
41
|
+
t.Fatalf("bad workflow status = %q reason=%q", bad.Status, bad.StatusReason)
|
|
42
|
+
}
|
|
43
|
+
bindings := registered.Bindings()
|
|
44
|
+
if len(bindings) != 1 || bindings[0].Workflow != good {
|
|
45
|
+
t.Fatalf("published bindings = %#v, want only goodFlow", bindings)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
func TestBindWorkflowsIsolatedSkipsOutdatedWorkflow(t *testing.T) {
|
|
50
|
+
registered := &Repo{Name: "payments", TaskSystem: bindingTaskSystem{}}
|
|
51
|
+
registry := NewRegistry()
|
|
52
|
+
registry.Replace(registered)
|
|
53
|
+
wf := &workflow.Workflow{Name: "editedFlow", Repos: []string{"payments"}, Status: workflow.HealthOutdated}
|
|
54
|
+
if issues := registry.BindWorkflowsIsolated([]*workflow.Workflow{wf}); len(issues) != 0 {
|
|
55
|
+
t.Fatalf("outdated workflow binding issues = %#v, want none", issues)
|
|
56
|
+
}
|
|
57
|
+
if bindings := registered.Bindings(); len(bindings) != 0 {
|
|
58
|
+
t.Fatalf("outdated workflow bindings = %#v, want none", bindings)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
func TestBindWorkflowsIsolatedBlocksUnavailableTaskSystem(t *testing.T) {
|
|
63
|
+
registered := &Repo{Name: "payments", TaskSystemError: errors.New("task service offline")}
|
|
64
|
+
registry := NewRegistry()
|
|
65
|
+
registry.Replace(registered)
|
|
66
|
+
wf := &workflow.Workflow{Name: "paymentsFlow", Repos: []string{"payments"}, Status: workflow.HealthHealthy}
|
|
67
|
+
issues := registry.BindWorkflowsIsolated([]*workflow.Workflow{wf})
|
|
68
|
+
if len(issues) != 1 || wf.Status != workflow.HealthBlocked || !strings.Contains(wf.StatusReason, "task service offline") {
|
|
69
|
+
t.Fatalf("unavailable task system issues=%#v status=%q reason=%q", issues, wf.Status, wf.StatusReason)
|
|
70
|
+
}
|
|
71
|
+
if bindings := registered.Bindings(); len(bindings) != 0 {
|
|
72
|
+
t.Fatalf("unavailable repo bindings = %#v, want none", bindings)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
func TestBindWorkflowsIsolatedClearsStaleBindings(t *testing.T) {
|
|
77
|
+
registered := &Repo{Name: "payments", TaskSystem: bindingTaskSystem{}}
|
|
78
|
+
registry := NewRegistry()
|
|
79
|
+
registry.Replace(registered)
|
|
80
|
+
first := &workflow.Workflow{Name: "firstFlow", Repos: []string{"payments"}, Status: workflow.HealthHealthy}
|
|
81
|
+
if issues := registry.BindWorkflowsIsolated([]*workflow.Workflow{first}); len(issues) != 0 {
|
|
82
|
+
t.Fatalf("initial binding issues = %#v", issues)
|
|
83
|
+
}
|
|
84
|
+
second := &workflow.Workflow{Name: "secondFlow", Repos: []string{"payments"}, Status: workflow.HealthHealthy}
|
|
85
|
+
if issues := registry.BindWorkflowsIsolated([]*workflow.Workflow{second}); len(issues) != 0 {
|
|
86
|
+
t.Fatalf("replacement binding issues = %#v", issues)
|
|
87
|
+
}
|
|
88
|
+
bindings := registered.Bindings()
|
|
89
|
+
if len(bindings) != 1 || bindings[0].Workflow != second {
|
|
90
|
+
t.Fatalf("replacement bindings = %#v, want only secondFlow", bindings)
|
|
91
|
+
}
|
|
92
|
+
}
|
package/internal/repo/poller.go
CHANGED