relay-flow 0.2.3-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.
Files changed (85) hide show
  1. package/README.md +121 -10
  2. package/cmd/relay-flow/backend_selection_test.go +149 -0
  3. package/cmd/relay-flow/main.go +96 -13
  4. package/cmd/relay-flow/pi_wiring_test.go +64 -0
  5. package/cmd/relay-flow/scenario_test.go +19 -2
  6. package/cmd/relay-flow/serve.go +100 -19
  7. package/cmd/relay-flow/serve_recovery_test.go +100 -0
  8. package/cmd/relay-flow/temporal_init.go +170 -0
  9. package/cmd/relay-flow/temporal_init_test.go +217 -0
  10. package/cmd/relay-flow/temporal_report_test.go +733 -0
  11. package/examples/beads-workflow.yaml +3 -3
  12. package/examples/config-reference.yaml +144 -0
  13. package/examples/minimal-beads-task-workflow.yaml +35 -0
  14. package/examples/minimal-jira-task-workflow.yaml +68 -0
  15. package/examples/workflow-reference.yaml +112 -0
  16. package/go.mod +37 -16
  17. package/go.sum +129 -61
  18. package/internal/config/machine.go +33 -1
  19. package/internal/config/machine_test.go +76 -0
  20. package/internal/execution/goworkflows/engine.go +13 -38
  21. package/internal/execution/goworkflows/projection.go +47 -464
  22. package/internal/execution/projection/projection.go +867 -0
  23. package/internal/execution/projection/projection_test.go +347 -0
  24. package/internal/execution/temporal/activities.go +567 -0
  25. package/internal/execution/temporal/engine.go +384 -0
  26. package/internal/execution/temporal/engine_test.go +277 -0
  27. package/internal/execution/temporal/interpreter.go +736 -0
  28. package/internal/execution/temporal/operations.go +455 -0
  29. package/internal/execution/temporal/operations_test.go +101 -0
  30. package/internal/execution/temporal/recovery.go +194 -0
  31. package/internal/execution/temporal/recovery_runtime.go +41 -0
  32. package/internal/execution/temporal/recovery_test.go +102 -0
  33. package/internal/execution/temporal/snapshot_restart_test.go +72 -0
  34. package/internal/execution/temporal/spike_test.go +934 -0
  35. package/internal/execution/temporal/visibility_lag_test.go +415 -0
  36. package/internal/harness/opencode/opencode.go +3 -1
  37. package/internal/harness/opencode/opencode_test.go +1 -1
  38. package/internal/harness/opencode/repo_setup.go +1 -1
  39. package/internal/harness/pi/config_test.go +46 -0
  40. package/internal/harness/pi/lifecycle_test.go +69 -0
  41. package/internal/harness/pi/pi.go +264 -0
  42. package/internal/harness/pi/pi_test.go +338 -0
  43. package/internal/harness/pi/prompt_test.go +150 -0
  44. package/internal/harness/pi/testdata/pi-0.84.1/capture.json +126 -0
  45. package/internal/harness/pi/testdata/pi-0.84.1/noninteractive-output.txt +11 -0
  46. package/internal/harness/pi/testdata/pi-0.84.1/tui-output-sanitized.txt +17 -0
  47. package/internal/harness/pi/validation_test.go +144 -0
  48. package/internal/runner/herdr/baseref.go +60 -0
  49. package/internal/runner/herdr/herdr.go +592 -0
  50. package/internal/runner/herdr/herdr_test.go +708 -0
  51. package/internal/runner/herdr/herdrcli/contract.go +128 -0
  52. package/internal/runner/herdr/herdrcli/exec.go +68 -0
  53. package/internal/runner/herdr/herdrcli/herdrcli_test.go +274 -0
  54. package/internal/runner/herdr/herdrcli/live_test.go +132 -0
  55. package/internal/runner/herdr/herdrcli/operations.go +281 -0
  56. package/internal/runner/herdr/herdrcli/response.go +116 -0
  57. package/internal/runner/herdr/herdrcli/testdata/empty-panes.json +1 -0
  58. package/internal/runner/herdr/herdrcli/testdata/empty-tabs.json +1 -0
  59. package/internal/runner/herdr/herdrcli/testdata/error-not-git-worktree.json +1 -0
  60. package/internal/runner/herdr/herdrcli/testdata/error-pane-not-found.json +1 -0
  61. package/internal/runner/herdr/herdrcli/testdata/error-workspace-not-found.json +1 -0
  62. package/internal/runner/herdr/herdrcli/testdata/error-worktree-not-found.json +1 -0
  63. package/internal/runner/herdr/herdrcli/testdata/malformed.json +1 -0
  64. package/internal/runner/herdr/herdrcli/testdata/pane-close.json +6 -0
  65. package/internal/runner/herdr/herdrcli/testdata/pane-get.json +25 -0
  66. package/internal/runner/herdr/herdrcli/testdata/pane-list.json +45 -0
  67. package/internal/runner/herdr/herdrcli/testdata/pane-process-info-shell.json +22 -0
  68. package/internal/runner/herdr/herdrcli/testdata/pane-process-info.json +23 -0
  69. package/internal/runner/herdr/herdrcli/testdata/pane-rename.json +23 -0
  70. package/internal/runner/herdr/herdrcli/testdata/snapshot.json +213 -0
  71. package/internal/runner/herdr/herdrcli/testdata/strict-herdr.sh +175 -0
  72. package/internal/runner/herdr/herdrcli/testdata/tab-create.json +31 -0
  73. package/internal/runner/herdr/herdrcli/testdata/tab-list.json +26 -0
  74. package/internal/runner/herdr/herdrcli/testdata/workspace-close.json +6 -0
  75. package/internal/runner/herdr/herdrcli/testdata/worktree-create.json +58 -0
  76. package/internal/runner/herdr/herdrcli/testdata/worktree-list.json +35 -0
  77. package/internal/runner/herdr/herdrcli/testdata/worktree-open.json +59 -0
  78. package/internal/runner/orca/orca.go +33 -0
  79. package/internal/runner/orca/orca_test.go +33 -4
  80. package/internal/runner/runner.go +8 -0
  81. package/internal/task/beads/beads.go +0 -16
  82. package/internal/task/beads/config_compatibility_test.go +7 -8
  83. package/internal/task/jira/filters_test.go +32 -6
  84. package/internal/task/jira/jira.go +27 -17
  85. package/package.json +1 -1
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # relay-flow
2
2
 
3
- Durable, graph-based agent workflow runner. A ticket is the unit of work; a workflow YAML declares nodes and routes; a durable engine (go-workflows + SQLite) drives progression, waits, retries, and recovery. The task system (Jira and Beads built-ins) supplies parent tickets and mailbox subtasks; the runner (Orca built-in) owns worktrees and terminals; the harness (OpenCode built-in) owns agent sessions and report semantics. All three are pluggable.
3
+ Durable, graph-based agent workflow runner. A ticket is the unit of work; a workflow YAML declares nodes and routes; a durable engine (go-workflows + SQLite) drives progression, waits, retries, and recovery. The task system (Jira and Beads built-ins) supplies parent tickets and mailbox subtasks; the runner (Orca built-in) owns worktrees and terminals; the harness (OpenCode and Pi built-ins) owns agent sessions and report semantics. All three are pluggable.
4
4
 
5
5
  This is a ground-up rewrite. The previous per-workflow, in-memory daemon is gone. There is no migration path and no compatibility layer.
6
6
 
@@ -51,6 +51,26 @@ assistant report it shows a native Approve/Reject dialog. Approval delivers
51
51
  delivers nothing. `reportId` comes from the harness session/message identity;
52
52
  `nodeVisitID` is internal and is never part of either plugin payload.
53
53
 
54
+ Pi plugin: install the same published package manually in Pi's global package
55
+ settings before starting a Pi harness session:
56
+
57
+ ```sh
58
+ pi install npm:relay-flow-plugin@<version>
59
+ ```
60
+
61
+ Relay-flow does not install or configure the package automatically. Pi resolves
62
+ `pi.ts` from the package's `pi.extensions` manifest entry, so do not add
63
+ `-e`/`--extension` to the relay-flow launch command. The runner launches Pi in
64
+ an interactive PTY; the package's OpenCode entry point remains available for
65
+ OpenCode sessions.
66
+
67
+ The plugin is the report-path half of the harness contract: it registers each
68
+ emitted harness session with `{runId, node, sessionId}`, parses the agent's
69
+ structured report, applies the agent/HITL nudge policy, and delivers
70
+ `{runId, node, reportId, report}` via `relay-flow report` with retry.
71
+ `reportId` comes from the harness session/message identity; `nodeVisitID` is
72
+ internal and is never part of either plugin payload.
73
+
54
74
  ### One-time machine setup
55
75
 
56
76
  ```sh
@@ -68,6 +88,13 @@ relay-flow init --task-plugin beads --runner-plugin orca --harness-plugin openco
68
88
 
69
89
  Beads authentication is owned by the Beads workspace and its `bd`/Dolt configuration. `relay-flow task auth` is a no-op for Beads and does not create relay-flow credentials; do not add a Jira token to a Beads-only installation.
70
90
 
91
+ Pi is available through the existing harness selection. For a non-interactive
92
+ setup, select it with the existing flags:
93
+
94
+ ```sh
95
+ relay-flow init --task-plugin jira --runner-plugin orca --harness-plugin pi
96
+ ```
97
+
71
98
  The full machine layout is fixed under `~/.relay-flow` (0700):
72
99
 
73
100
  ```
@@ -174,10 +201,14 @@ Beads workflow filters are structured and evaluated in relay-flow. For example,
174
201
  taskConfig:
175
202
  filters:
176
203
  parentStatuses: [open]
177
- issueTypes: [epic]
204
+ issueTypes: [task]
178
205
  labels: [relay-ready]
179
206
  ```
180
207
 
208
+ Jira and Beads use the same conceptual `Task` issue type, but each adapter
209
+ keeps its provider-native spelling: Jira uses `Task` and Beads uses `task`.
210
+ Filter values are exact and are not translated between providers.
211
+
181
212
  The supported Beads status names are `open`, `in_progress`, `blocked`, `deferred`, `hooked`, and `closed`. The claimed-parent poll uses the canonical active set `open,in_progress,blocked,deferred`; it intentionally does not substitute `hooked` for `deferred`. Omitted lifecycle settings move the parent to `in_progress` at `start`, a work-node mailbox to `in_progress`, and the parent to `closed` at `end` — the same shape as Jira, with Beads-native values. Relay-flow creates one Repo Poller per registered repo, not one poller per workflow. Each poll reads ready top-level parents and relay-owned active parents, deduplicates them, and never routes mailbox children. Claims are permanent `wf:<workflow>` labels.
182
213
 
183
214
  Beads does not need relay-flow credentials or a Beads-specific poller. In server mode, leave Dolt and Beads server setup running outside relay-flow and point each repo at its own `beadsDir`.
@@ -190,7 +221,12 @@ relay-flow workflow submit --file <path>
190
221
 
191
222
  Workflows live at `~/.relay-flow/workflows/<name>.yaml` after submit. Replacement and removal are rejected while any run of that workflow is active.
192
223
 
193
- Use [`examples/default-story-workflow.yaml`](examples/default-story-workflow.yaml) as a fully annotated Jira-oriented starting point, or [`examples/beads-workflow.yaml`](examples/beads-workflow.yaml) for the Beads filter and lifecycle shape. Replace the repo name and uncomment only the optional fields you need.
224
+ Use [`examples/config-reference.yaml`](examples/config-reference.yaml) for the complete machine configuration, [`examples/workflow-reference.yaml`](examples/workflow-reference.yaml) for the complete workflow schema, or the provider-specific minimal workflows [`examples/minimal-jira-task-workflow.yaml`](examples/minimal-jira-task-workflow.yaml) and [`examples/minimal-beads-task-workflow.yaml`](examples/minimal-beads-task-workflow.yaml). Runtime node agents should follow [`docs/agent-instructions.md`](docs/agent-instructions.md). The existing [`examples/default-story-workflow.yaml`](examples/default-story-workflow.yaml) remains a more detailed Jira Story example, while [`examples/beads-workflow.yaml`](examples/beads-workflow.yaml) shows the Beads lifecycle shape. Replace the repo name and uncomment only the optional fields you need.
225
+
226
+ Task, runner, and harness plugins are selected machine-wide. A single relay-flow
227
+ configuration cannot run Jira and Beads simultaneously; use separate
228
+ `RELAY_FLOW_HOME` directories or machine configurations when both providers are
229
+ needed.
194
230
 
195
231
  ### Run
196
232
 
@@ -284,19 +320,94 @@ A parent moved to `in_progress` stays visible to the claimed-parent poll, which
284
320
 
285
321
  Beads and Jira use the shared `filters`, `templates`, optional top-level
286
322
  `assignee`, and `transitionTo` field names. `transitionTo` uses
287
- `parentStatus` for the parent issue and `taskStatus` for a mailbox. In both
288
- adapters `assignee` is the default assignee filter when `filters.assignees` is
289
- absent, and an `assignee` in effect for a node also assigns that node's
290
- mailbox. Beads requires the repo-only `taskConfig.beadsDir` shown above; Jira
291
- instead uses its repo `project` and `component` keys. `project` and
292
- `component` are not Beads fields, and a Beads issue prefix is not a component
293
- or workspace selector. Beads status values remain native (`open`,
323
+ `parentStatus` for the parent issue and `taskStatus` for a mailbox. Both
324
+ adapters support the structured `filters.assignees` list. Jira values are
325
+ normalized account emails; Beads values are the provider's assignee strings.
326
+ Jira additionally supports the reserved `currentUser()` value inside
327
+ `filters.assignees`; relay-flow resolves it to the authenticated Jira email
328
+ without sending it as JQL. On first Jira authentication, when no root
329
+ `taskConfig.assignee` is configured, relay-flow stores the authenticated email
330
+ as the root mailbox assignee. In both adapters `assignee` applies to a node's
331
+ mailbox assignment; only `filters.assignees` controls parent-ticket pickup.
332
+ When `filters.assignees` is absent, there is no assignee filter. To make that
333
+ explicit while keeping mailbox assignment, set an empty list:
334
+
335
+ ```yaml
336
+ taskConfig:
337
+ filters:
338
+ assignees: []
339
+ ```
340
+
341
+ This still applies the other filters, such as project, status, issue type, and
342
+ labels. If mailbox assignment should also be disabled for a workflow, override
343
+ `assignee` with an empty string at that scope as well. Beads requires the repo-only
344
+ `taskConfig.beadsDir` shown above; Jira instead uses its repo `project` and
345
+ `component` keys. `project` and `component` are not Beads fields, and a Beads
346
+ issue prefix is not a component or workspace selector. Beads status values remain native (`open`,
294
347
  `in_progress`, `blocked`, `deferred`, `hooked`, `closed`), while Jira values
295
348
  remain native (`In Progress`, `Done`, and so on); relay-flow does not
296
349
  translate arbitrary values between providers. Beads rejects workflow/node-level
297
350
  template overrides because the fixed task text rendering contract has no
298
351
  lower-scope input.
299
352
 
353
+ ### Pi harness
354
+
355
+ Pi has one built-in coding agent. Relay-flow keeps Pi's configured model,
356
+ provider, tools, extensions, and settings, while allowing a workflow node to
357
+ select a project-owned prompt template. `agent: default` uses Pi's built-in
358
+ coding agent without an additional prompt template. A non-default value such as
359
+ `coder` or `reviewer` selects Pi's native project template
360
+ `.pi/prompts/<agent>.md` and submits the rendered task text as the template's
361
+ arguments with `/<agent> ...`.
362
+
363
+ Pi owns prompt-template discovery and expansion. Relay-flow does not maintain a
364
+ named-agent registry or inspect template files; it only validates the safe
365
+ template command name and passes Pi the corresponding
366
+ `--prompt-template .pi/prompts/<agent>.md` resource. The workflow agent value is
367
+ never treated as a model ID and relay-flow never passes an OpenCode-style
368
+ `--agent` option.
369
+
370
+ For example, a Pi workflow with project prompt templates uses:
371
+
372
+ ```yaml
373
+ type: agent
374
+ agent: coder
375
+ ```
376
+
377
+ with project templates:
378
+
379
+ ```text
380
+ .pi/prompts/coder.md
381
+ .pi/prompts/reviewer.md
382
+ ```
383
+
384
+ Pi node launches use the installed Pi interactive command contract:
385
+
386
+ ```text
387
+ pi --name <ticket>:<node> [--prompt-template .pi/prompts/<agent>.md] [--session-id <persisted-session-id>] <prompt>
388
+ ```
389
+
390
+ For a named agent, `<prompt>` is `/agent <rendered relay-flow task or
391
+ feedback prompt>`; `default` keeps the rendered prompt unchanged.
392
+
393
+ The prompt is one positional argument. Pi 0.84.1 rejects a bare `--`, so the
394
+ launch command does not include one. A persisted session uses
395
+ `--session-id`; print mode, JSON/RPC mode, and extension-install flags are not
396
+ used. The runner supplies a PTY for both standard streams, and the Pi process
397
+ remains available for interactive input after a response settles.
398
+
399
+ For a `hitl` node, a valid report is approved directly in Pi's host UI with
400
+ `ctx.ui.select`:
401
+
402
+ ```text
403
+ Approve relay-flow report for <ticket>:<node>
404
+ Approve
405
+ Reject
406
+ ```
407
+
408
+ Approve delivers the report. Reject or Escape submits nothing and leaves the
409
+ durable run waiting; Pi does not require an LLM Question tool for this step.
410
+
300
411
  ---
301
412
 
302
413
  ## Structured node report
@@ -0,0 +1,149 @@
1
+ package main
2
+
3
+ import (
4
+ "context"
5
+ "database/sql"
6
+ "fmt"
7
+ "os"
8
+ "path/filepath"
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/paths"
15
+ "github.com/rajpopat27/relay-flow/internal/server"
16
+ _ "modernc.org/sqlite"
17
+ )
18
+
19
+ // These integration tests are deliberately live-gated because they exercise
20
+ // the selected durable backend and the local Temporal service. They use the
21
+ // same fake task/runner/harness factories as the composition scenario, but no
22
+ // task-system state or workflow is needed to prove worker selection.
23
+ func TestServeSelectsConfiguredTemporalExecutor(t *testing.T) {
24
+ if os.Getenv("RELAY_FLOW_TEMPORAL_LIVE") != "1" {
25
+ t.Skip("set RELAY_FLOW_TEMPORAL_LIVE=1 to run backend selection against Temporal")
26
+ }
27
+ assertServeUsesTemporal(t, false)
28
+ }
29
+
30
+ func TestServeRecoverSelectsConfiguredTemporalExecutor(t *testing.T) {
31
+ if os.Getenv("RELAY_FLOW_TEMPORAL_LIVE") != "1" {
32
+ t.Skip("set RELAY_FLOW_TEMPORAL_LIVE=1 to run backend recovery against Temporal")
33
+ }
34
+ assertServeUsesTemporal(t, true)
35
+ }
36
+
37
+ func assertServeUsesTemporal(t *testing.T, recover bool) {
38
+ t.Helper()
39
+ log := newScenarioLog()
40
+ setScenarioFactoryAdapters(newScenarioTaskSystem(log), newScenarioRunner(log), newScenarioHarness(log))
41
+
42
+ root := filepath.Join(t.TempDir(), ".relay-flow")
43
+ t.Setenv("RELAY_FLOW_HOME", root)
44
+ p := pathsForRoot(root)
45
+ if err := paths.Ensure(p); err != nil {
46
+ t.Fatal(err)
47
+ }
48
+ namespace := fmt.Sprintf("relay-flow-serve-%d", time.Now().UnixNano())
49
+ if err := ensureTemporalNamespace(context.Background(), "localhost:7233", namespace, 30); err != nil {
50
+ t.Fatal(err)
51
+ }
52
+ // Namespace registration is acknowledged before every frontend worker
53
+ // necessarily observes it.
54
+ time.Sleep(5 * time.Second)
55
+ if err := config.SaveMachine(p.Config, &config.Machine{
56
+ TaskPlugin: scenarioTaskPlugin,
57
+ RunnerPlugin: scenarioRunnerPlugin,
58
+ HarnessPlugin: scenarioHarnessPlugin,
59
+ ExecutorPlugin: "temporal",
60
+ TemporalAddress: "localhost:7233",
61
+ TemporalNamespace: namespace,
62
+ CompletedRunRetentionDays: 30,
63
+ }); err != nil {
64
+ t.Fatal(err)
65
+ }
66
+ if err := projection.InitDatabaseWithIdentity(p.Database, projection.ExecutorIdentity{
67
+ ExecutorPlugin: "temporal", TemporalAddress: "localhost:7233", TemporalNamespace: namespace,
68
+ }); err != nil {
69
+ t.Fatal(err)
70
+ }
71
+
72
+ ctx, cancel := context.WithCancel(context.Background())
73
+ defer cancel()
74
+ done := make(chan error, 1)
75
+ go func() { done <- serveRoot(ctx, p, recover) }()
76
+ client := server.NewClient(p.Socket)
77
+ waitForServerOrError(t, client, done)
78
+
79
+ assertNoEmbeddedExecutionTables(t, p.Database)
80
+ if err := client.Stop(context.Background()); err != nil {
81
+ t.Fatal(err)
82
+ }
83
+ select {
84
+ case err := <-done:
85
+ if err != nil {
86
+ t.Fatalf("serveRoot(%v): %v", recover, err)
87
+ }
88
+ case <-time.After(15 * time.Second):
89
+ t.Fatalf("serveRoot(%v) did not stop", recover)
90
+ }
91
+ }
92
+
93
+ func waitForServerOrError(t *testing.T, client *server.Client, done <-chan error) {
94
+ t.Helper()
95
+ deadline := time.Now().Add(20 * time.Second)
96
+ for time.Now().Before(deadline) {
97
+ select {
98
+ case err := <-done:
99
+ t.Fatalf("serveRoot exited before socket became ready: %v", err)
100
+ default:
101
+ }
102
+ ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
103
+ _, err := client.ListRepos(ctx)
104
+ cancel()
105
+ if err == nil {
106
+ return
107
+ }
108
+ time.Sleep(20 * time.Millisecond)
109
+ }
110
+ select {
111
+ case err := <-done:
112
+ t.Fatalf("serveRoot exited before socket became ready: %v", err)
113
+ default:
114
+ t.Fatal("server did not become ready")
115
+ }
116
+ }
117
+
118
+ func assertNoEmbeddedExecutionTables(t *testing.T, databasePath string) {
119
+ t.Helper()
120
+ db, err := sql.Open("sqlite", databasePath)
121
+ if err != nil {
122
+ t.Fatal(err)
123
+ }
124
+ defer db.Close()
125
+ var count int
126
+ if err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name IN ('instances', 'history_events', 'activity_tasks')`).Scan(&count); err != nil {
127
+ t.Fatal(err)
128
+ }
129
+ if count != 0 {
130
+ t.Fatalf("Temporal projection contains embedded go-workflows tables: %d", count)
131
+ }
132
+ var plugin, address, namespace string
133
+ if err := db.QueryRow(`SELECT executor_plugin, temporal_address, temporal_namespace FROM relay_executor_identity WHERE singleton = 1`).Scan(&plugin, &address, &namespace); err != nil {
134
+ t.Fatal(err)
135
+ }
136
+ if plugin != "temporal" || address != "localhost:7233" || namespace == "" {
137
+ t.Fatalf("executor identity = %q/%q/%q", plugin, address, namespace)
138
+ }
139
+ }
140
+
141
+ func pathsForRoot(root string) paths.Paths {
142
+ return paths.Paths{
143
+ Root: root, Config: filepath.Join(root, "config.yaml"),
144
+ Credentials: filepath.Join(root, "credentials.yaml"), Workflows: filepath.Join(root, "workflows"),
145
+ Database: filepath.Join(root, "state.db"), Socket: filepath.Join(root, "server.sock"),
146
+ Lock: filepath.Join(root, "server.lock"), ServerLog: filepath.Join(root, "server.log"),
147
+ PluginLog: filepath.Join(root, "plugin.log"),
148
+ }
149
+ }
@@ -24,7 +24,7 @@ import (
24
24
  "github.com/charmbracelet/huh"
25
25
  "github.com/mattn/go-isatty"
26
26
  "github.com/rajpopat27/relay-flow/internal/config"
27
- "github.com/rajpopat27/relay-flow/internal/execution/goworkflows"
27
+ "github.com/rajpopat27/relay-flow/internal/execution/projection"
28
28
  "github.com/rajpopat27/relay-flow/internal/harness"
29
29
  "github.com/rajpopat27/relay-flow/internal/logging"
30
30
  "github.com/rajpopat27/relay-flow/internal/paths"
@@ -37,6 +37,8 @@ import (
37
37
  // Adapter registrations (factories registered via init for plugin
38
38
  // name validation at init-time).
39
39
  _ "github.com/rajpopat27/relay-flow/internal/harness/opencode"
40
+ _ "github.com/rajpopat27/relay-flow/internal/harness/pi"
41
+ _ "github.com/rajpopat27/relay-flow/internal/runner/herdr"
40
42
  _ "github.com/rajpopat27/relay-flow/internal/runner/orca"
41
43
  _ "github.com/rajpopat27/relay-flow/internal/task/beads"
42
44
  _ "github.com/rajpopat27/relay-flow/internal/task/jira"
@@ -132,6 +134,8 @@ func usage(w io.Writer) {
132
134
 
133
135
  Usage:
134
136
  relay-flow init [--force] [--task-plugin <name> --runner-plugin <name> --harness-plugin <name>]
137
+ [--executor-plugin <goworkflows|temporal>]
138
+ [--temporal-address <host:port>] [--temporal-namespace <name>]
135
139
  relay-flow task auth [task-plugin options]
136
140
  relay-flow serve [--recover] [--debug] [--background]
137
141
  relay-flow stop
@@ -168,6 +172,9 @@ func cmdInit(p paths.Paths, args []string, stdin io.Reader) int {
168
172
  taskName := fs.String("task-plugin", "", "task plugin name (non-interactive)")
169
173
  runnerName := fs.String("runner-plugin", "", "runner plugin name (non-interactive)")
170
174
  harnessName := fs.String("harness-plugin", "", "harness plugin name (non-interactive)")
175
+ executorName := fs.String("executor-plugin", "", "durable executor name (goworkflows or temporal)")
176
+ temporalAddress := fs.String("temporal-address", "", "Temporal server address")
177
+ temporalNamespace := fs.String("temporal-namespace", "", "Temporal namespace/team name")
171
178
  force := fs.Bool("force", false, "update plugin selections while preserving existing state")
172
179
  if err := fs.Parse(args); err != nil {
173
180
  return exitUsage
@@ -213,7 +220,7 @@ func cmdInit(p paths.Paths, args []string, stdin io.Reader) int {
213
220
  }
214
221
  defer unlock()
215
222
  if databaseExists {
216
- active, err := goworkflows.HasNonterminalRuns(p.Database)
223
+ active, err := projection.HasNonterminalRuns(p.Database)
217
224
  if err != nil {
218
225
  fmt.Fprintln(os.Stderr, "init: "+err.Error())
219
226
  return exitFail
@@ -225,9 +232,11 @@ func cmdInit(p paths.Paths, args []string, stdin io.Reader) int {
225
232
  }
226
233
  }
227
234
 
228
- // Selection precedence: flags → TTY form → stdin lines. The stdin path
229
- // is the documented test seam and script path.
235
+ // Selection precedence: flags → TTY form → legacy stdin lines. The
236
+ // three-line stdin path remains embedded-mode input and never consumes
237
+ // Temporal answers.
230
238
  var names []string
239
+ executor := strings.TrimSpace(*executorName)
231
240
  switch {
232
241
  case flagged:
233
242
  names = []string{*taskName, *runnerName, *harnessName}
@@ -238,6 +247,10 @@ func cmdInit(p paths.Paths, args []string, stdin io.Reader) int {
238
247
  fmt.Fprintln(os.Stderr, "init: "+err.Error())
239
248
  return exitFail
240
249
  }
250
+ if len(names) == 4 {
251
+ executor = names[3]
252
+ names = names[:3]
253
+ }
241
254
  default:
242
255
  names = readInitLines(stdin)
243
256
  if names == nil {
@@ -245,6 +258,22 @@ func cmdInit(p paths.Paths, args []string, stdin io.Reader) int {
245
258
  return exitFail
246
259
  }
247
260
  }
261
+ if executor == executorTemporal && isTTY(stdin) {
262
+ address, namespace, err := promptTemporalSettings(*temporalAddress, *temporalNamespace)
263
+ if err != nil {
264
+ fmt.Fprintln(os.Stderr, "init: "+err.Error())
265
+ return exitFail
266
+ }
267
+ *temporalAddress = address
268
+ *temporalNamespace = namespace
269
+ }
270
+ if executor == "" {
271
+ executor = executorGoworkflows
272
+ }
273
+ if executor != executorGoworkflows && executor != executorTemporal {
274
+ fmt.Fprintln(os.Stderr, "init: unknown executor plugin "+executor)
275
+ return exitUsage
276
+ }
248
277
  // Validate against the registered factories; unknown names list the
249
278
  // registered set per design (no silent acceptance).
250
279
  if err := task.ValidateName(names[0]); err != nil {
@@ -260,13 +289,42 @@ func cmdInit(p paths.Paths, args []string, stdin io.Reader) int {
260
289
  return exitFail
261
290
  }
262
291
 
263
- cfg := &config.Machine{KeepTerminalsAlive: true, KeepSessionsAlive: true}
292
+ cfg := &config.Machine{
293
+ PollIntervalSeconds: 15, CompletedRunRetentionDays: 30,
294
+ KeepTerminalsAlive: true, KeepSessionsAlive: true,
295
+ ExecutorPlugin: executor,
296
+ }
264
297
  if *force && configExists {
265
298
  cfg, err = config.LoadMachine(p.Config)
266
299
  if err != nil {
267
300
  fmt.Fprintln(os.Stderr, err)
268
301
  return exitFail
269
302
  }
303
+ if strings.TrimSpace(*executorName) == "" {
304
+ executor = cfg.ExecutorPlugin
305
+ }
306
+ cfg.ExecutorPlugin = executor
307
+ }
308
+ if executor == executorTemporal {
309
+ if strings.TrimSpace(*temporalAddress) != "" {
310
+ cfg.TemporalAddress = strings.TrimSpace(*temporalAddress)
311
+ } else if cfg.TemporalAddress == "" {
312
+ cfg.TemporalAddress = defaultTemporalAddr
313
+ }
314
+ if strings.TrimSpace(*temporalNamespace) != "" {
315
+ cfg.TemporalNamespace = strings.TrimSpace(*temporalNamespace)
316
+ }
317
+ if cfg.TemporalNamespace == "" {
318
+ fmt.Fprintln(os.Stderr, "init: --temporal-namespace is required for executor-plugin temporal")
319
+ return exitUsage
320
+ }
321
+ } else {
322
+ if strings.TrimSpace(*temporalAddress) != "" || strings.TrimSpace(*temporalNamespace) != "" {
323
+ fmt.Fprintln(os.Stderr, "init: Temporal address/namespace require executor-plugin temporal")
324
+ return exitUsage
325
+ }
326
+ cfg.TemporalAddress = ""
327
+ cfg.TemporalNamespace = ""
270
328
  }
271
329
  cfg.TaskPlugin = names[0]
272
330
  cfg.RunnerPlugin = names[1]
@@ -295,17 +353,37 @@ func cmdInit(p paths.Paths, args []string, stdin io.Reader) int {
295
353
  } else {
296
354
  cfg.HarnessConfig = config.Merge(harnessDefaults, cfg.HarnessConfig)
297
355
  }
298
- if err := config.SaveMachine(p.Config, cfg); err != nil {
299
- fmt.Fprintln(os.Stderr, err)
300
- return exitFail
356
+ identity := projection.ExecutorIdentity{
357
+ ExecutorPlugin: executor,
358
+ TemporalAddress: cfg.TemporalAddress,
359
+ TemporalNamespace: cfg.TemporalNamespace,
360
+ }
361
+ // Force mode verifies the immutable durable identity before contacting
362
+ // Temporal or replacing configuration. Legacy marker-less homes may only
363
+ // be adopted by goworkflows.
364
+ if databaseExists {
365
+ if err := verifyExecutorIdentity(p.Database, identity); err != nil {
366
+ fmt.Fprintln(os.Stderr, "init: "+err.Error())
367
+ return exitFail
368
+ }
369
+ }
370
+ if executor == executorTemporal {
371
+ if err := ensureTemporalNamespace(context.Background(), cfg.TemporalAddress, cfg.TemporalNamespace, cfg.CompletedRunRetentionDays); err != nil {
372
+ fmt.Fprintln(os.Stderr, "init: "+err.Error())
373
+ return exitFail
374
+ }
301
375
  }
302
376
  if !databaseExists {
303
- if err := goworkflows.InitDatabase(p.Database); err != nil {
304
- fmt.Fprintln(os.Stderr, err)
377
+ if err := projection.InitDatabaseWithIdentity(p.Database, identity); err != nil {
378
+ fmt.Fprintln(os.Stderr, "init: "+err.Error())
305
379
  return exitFail
306
380
  }
307
381
  }
308
- fmt.Printf("Task system: %s\nRunner: %s\nHarness: %s\nRelay-flow initialized\n", names[0], names[1], names[2])
382
+ if err := config.SaveMachine(p.Config, cfg); err != nil {
383
+ fmt.Fprintln(os.Stderr, err)
384
+ return exitFail
385
+ }
386
+ fmt.Printf("Task system: %s\nRunner: %s\nHarness: %s\nExecutor: %s\nRelay-flow initialized\n", names[0], names[1], names[2], executor)
309
387
  return exitOK
310
388
  }
311
389
 
@@ -343,8 +421,8 @@ func isTTY(stdin io.Reader) bool {
343
421
 
344
422
  // pickPluginsInteractive runs one searchable select per plugin type.
345
423
  func pickPluginsInteractive() ([]string, error) {
346
- names := make([]string, 3)
347
- groups := make([]*huh.Group, 0, 3)
424
+ names := make([]string, 4)
425
+ groups := make([]*huh.Group, 0, 4)
348
426
  for _, selection := range []struct {
349
427
  title string
350
428
  options []string
@@ -362,6 +440,11 @@ func pickPluginsInteractive() ([]string, error) {
362
440
  groups = append(groups, huh.NewGroup(field))
363
441
  }
364
442
  }
443
+ executorField, err := executorSelectField(&names[3])
444
+ if err != nil {
445
+ return nil, err
446
+ }
447
+ groups = append(groups, huh.NewGroup(executorField))
365
448
  if len(groups) == 0 {
366
449
  return names, nil
367
450
  }
@@ -0,0 +1,64 @@
1
+ package main
2
+
3
+ import (
4
+ "bytes"
5
+ "path/filepath"
6
+ "strings"
7
+ "testing"
8
+
9
+ "github.com/rajpopat27/relay-flow/internal/config"
10
+ "github.com/rajpopat27/relay-flow/internal/harness"
11
+ )
12
+
13
+ func TestPiAppearsInDynamicHarnessSelection(t *testing.T) {
14
+ names := harness.Names()
15
+ if !containsHarnessName(names, "pi") {
16
+ t.Fatalf("harness.Names() = %v, want pi", names)
17
+ }
18
+
19
+ var selected string
20
+ field, err := pluginSelectField("Select harness", names, &selected)
21
+ if err != nil {
22
+ t.Fatalf("pluginSelectField: %v", err)
23
+ }
24
+ var output bytes.Buffer
25
+ if err := field.RunAccessible(&output, strings.NewReader("1\n")); err != nil {
26
+ t.Fatalf("RunAccessible: %v", err)
27
+ }
28
+ if !strings.Contains(output.String(), "Select harness") {
29
+ t.Fatalf("selection output %q missing unchanged harness title", output.String())
30
+ }
31
+ }
32
+
33
+ func TestInitPiFlagUsesGenericHarnessSelectionPath(t *testing.T) {
34
+ home := t.TempDir()
35
+ code, output := captureStdout(t, func() int {
36
+ return cli(t, home, "", "init",
37
+ "--task-plugin", "jira",
38
+ "--runner-plugin", "orca",
39
+ "--harness-plugin", "pi")
40
+ })
41
+ if code != 0 {
42
+ t.Fatalf("Pi init exit = %d, want 0", code)
43
+ }
44
+ if !strings.Contains(output, "Harness: pi") {
45
+ t.Fatalf("Pi init output = %q, want generic harness summary", output)
46
+ }
47
+
48
+ cfg, err := config.LoadMachine(filepath.Join(home, ".relay-flow", "config.yaml"))
49
+ if err != nil {
50
+ t.Fatalf("load Pi machine config: %v", err)
51
+ }
52
+ if cfg.HarnessPlugin != "pi" {
53
+ t.Fatalf("harnessPlugin = %q, want pi", cfg.HarnessPlugin)
54
+ }
55
+ }
56
+
57
+ func containsHarnessName(names []string, want string) bool {
58
+ for _, name := range names {
59
+ if name == want {
60
+ return true
61
+ }
62
+ }
63
+ return false
64
+ }
@@ -801,6 +801,8 @@ type scenarioTaskSystem struct {
801
801
  creates map[string]int
802
802
  completions map[string]int
803
803
  completeFailures int
804
+ recoveryTickets []task.Ticket
805
+ recoveryPolls int
804
806
  }
805
807
 
806
808
  var _ task.System = (*scenarioTaskSystem)(nil)
@@ -816,11 +818,19 @@ func newScenarioTaskSystem(log *scenarioLog) *scenarioTaskSystem {
816
818
  func (s *scenarioTaskSystem) Poll(context.Context) ([]task.Ticket, error) {
817
819
  s.log.add("poll")
818
820
  s.mu.Lock()
819
- defer s.mu.Unlock()
820
821
  if s.claimed {
822
+ if s.recoveryPolls > 0 {
823
+ s.recoveryPolls--
824
+ tickets := append([]task.Ticket(nil), s.recoveryTickets...)
825
+ s.mu.Unlock()
826
+ return tickets, nil
827
+ }
828
+ s.mu.Unlock()
821
829
  return nil, nil
822
830
  }
823
- return []task.Ticket{{ID: "ticket-1", Key: scenarioTicket, Title: "Scenario ticket"}}, nil
831
+ tickets := []task.Ticket{{ID: "ticket-1", Key: scenarioTicket, Title: "Scenario ticket"}}
832
+ s.mu.Unlock()
833
+ return tickets, nil
824
834
  }
825
835
 
826
836
  func (s *scenarioTaskSystem) CompileFilter(config.RawValues) (func(task.Ticket) bool, error) {
@@ -958,6 +968,13 @@ func (s *scenarioTaskSystem) setCompleteFailures(n int) {
958
968
  s.mu.Unlock()
959
969
  }
960
970
 
971
+ func (s *scenarioTaskSystem) setRecoveryTickets(tickets []task.Ticket, polls int) {
972
+ s.mu.Lock()
973
+ s.recoveryTickets = append([]task.Ticket(nil), tickets...)
974
+ s.recoveryPolls = polls
975
+ s.mu.Unlock()
976
+ }
977
+
961
978
  func (s *scenarioTaskSystem) mailboxCreateCount(node string) int {
962
979
  s.mu.Lock()
963
980
  defer s.mu.Unlock()