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 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.7-alpha
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.7-alpha
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.7-alpha"]
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.7-alpha"]
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.7-alpha
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
@@ -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
- validMark := statusMark("valid", options.ASCII)
481
- fmt.Fprintf(w, "STATUS %s VALID\n", validMark)
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: func(context.Context, task.RepoSpec) (task.System, error) {
51
- scenarioFactoryMu.Lock()
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
- "task-config-validated",
217
- "runner-repo-validated",
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",
@@ -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 validate each workflow against every referenced
54
- // repo task system → bind workflows+matchers to repos → open go-workflows
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
- ts, err := task.New(ctx, cfg.TaskPlugin, task.RepoSpec{
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
- if err != nil {
114
- return fmt.Errorf("repo %q task system: %w", name, err)
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: name,
118
- Path: rc.Path,
119
- TaskConfig: rc.TaskConfig,
120
- TaskSystem: ts,
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
- workflowList, err := store.LoadAll()
139
+ records, err := store.LoadAllRecords()
127
140
  if err != nil {
128
141
  return fmt.Errorf("load workflows: %w", err)
129
142
  }
130
-
131
- // Validate each workflow structurally (lifecycle nodes, routes,
132
- // reachability, nudge templates) before any per-repo validation or
133
- // binding; an invalid stored workflow must fail startup, not bind.
134
- for _, wf := range workflowList {
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
- // Validate each workflow against every referenced repo task system.
141
- for _, wf := range workflowList {
142
- nodeCfgs := map[string]config.RawValues{}
143
- for nodeName, n := range wf.Nodes {
144
- nodeCfgs[nodeName] = n.TaskConfig
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
- if err := rp.TaskSystem.ValidateConfig(ctx, wf.TaskConfig, nodeCfgs); err != nil {
152
- return fmt.Errorf("workflow %q repo %q: %w", wf.Name, repoName, err)
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
- // Bind workflows and compiled matchers to repos.
158
- if err := repoReg.BindWorkflows(workflowList); err != nil {
159
- return err
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.7-alpha"
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")
@@ -10,7 +10,7 @@ import (
10
10
  "github.com/rajpopat27/relay-flow/internal/config"
11
11
  )
12
12
 
13
- const relayFlowPlugin = "relay-flow-plugin@0.3.7-alpha"
13
+ const relayFlowPlugin = "relay-flow-plugin@0.3.8-alpha"
14
14
 
15
15
  type jsoncToken struct {
16
16
  kind byte
@@ -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
+ }
@@ -39,6 +39,9 @@ func (p *RepoPoller) Run(ctx context.Context) {
39
39
  }
40
40
 
41
41
  func (p *RepoPoller) poll(ctx context.Context) {
42
+ if p.Repo == nil || p.Repo.TaskSystem == nil {
43
+ return
44
+ }
42
45
  if p.sem != nil {
43
46
  select {
44
47
  case p.sem <- struct{}{}: