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.
- package/README.md +121 -10
- package/cmd/relay-flow/backend_selection_test.go +149 -0
- package/cmd/relay-flow/main.go +96 -13
- package/cmd/relay-flow/pi_wiring_test.go +64 -0
- package/cmd/relay-flow/scenario_test.go +19 -2
- package/cmd/relay-flow/serve.go +100 -19
- package/cmd/relay-flow/serve_recovery_test.go +100 -0
- package/cmd/relay-flow/temporal_init.go +170 -0
- package/cmd/relay-flow/temporal_init_test.go +217 -0
- package/cmd/relay-flow/temporal_report_test.go +733 -0
- package/examples/beads-workflow.yaml +3 -3
- package/examples/config-reference.yaml +144 -0
- package/examples/minimal-beads-task-workflow.yaml +35 -0
- package/examples/minimal-jira-task-workflow.yaml +68 -0
- package/examples/workflow-reference.yaml +112 -0
- package/go.mod +37 -16
- package/go.sum +129 -61
- package/internal/config/machine.go +33 -1
- package/internal/config/machine_test.go +76 -0
- package/internal/execution/goworkflows/engine.go +13 -38
- package/internal/execution/goworkflows/projection.go +47 -464
- package/internal/execution/projection/projection.go +867 -0
- package/internal/execution/projection/projection_test.go +347 -0
- package/internal/execution/temporal/activities.go +567 -0
- package/internal/execution/temporal/engine.go +384 -0
- package/internal/execution/temporal/engine_test.go +277 -0
- package/internal/execution/temporal/interpreter.go +736 -0
- package/internal/execution/temporal/operations.go +455 -0
- package/internal/execution/temporal/operations_test.go +101 -0
- package/internal/execution/temporal/recovery.go +194 -0
- package/internal/execution/temporal/recovery_runtime.go +41 -0
- package/internal/execution/temporal/recovery_test.go +102 -0
- package/internal/execution/temporal/snapshot_restart_test.go +72 -0
- package/internal/execution/temporal/spike_test.go +934 -0
- package/internal/execution/temporal/visibility_lag_test.go +415 -0
- package/internal/harness/opencode/opencode.go +3 -1
- package/internal/harness/opencode/opencode_test.go +1 -1
- package/internal/harness/opencode/repo_setup.go +1 -1
- package/internal/harness/pi/config_test.go +46 -0
- package/internal/harness/pi/lifecycle_test.go +69 -0
- package/internal/harness/pi/pi.go +264 -0
- package/internal/harness/pi/pi_test.go +338 -0
- package/internal/harness/pi/prompt_test.go +150 -0
- package/internal/harness/pi/testdata/pi-0.84.1/capture.json +126 -0
- package/internal/harness/pi/testdata/pi-0.84.1/noninteractive-output.txt +11 -0
- package/internal/harness/pi/testdata/pi-0.84.1/tui-output-sanitized.txt +17 -0
- package/internal/harness/pi/validation_test.go +144 -0
- package/internal/runner/herdr/baseref.go +60 -0
- package/internal/runner/herdr/herdr.go +592 -0
- package/internal/runner/herdr/herdr_test.go +708 -0
- package/internal/runner/herdr/herdrcli/contract.go +128 -0
- package/internal/runner/herdr/herdrcli/exec.go +68 -0
- package/internal/runner/herdr/herdrcli/herdrcli_test.go +274 -0
- package/internal/runner/herdr/herdrcli/live_test.go +132 -0
- package/internal/runner/herdr/herdrcli/operations.go +281 -0
- package/internal/runner/herdr/herdrcli/response.go +116 -0
- package/internal/runner/herdr/herdrcli/testdata/empty-panes.json +1 -0
- package/internal/runner/herdr/herdrcli/testdata/empty-tabs.json +1 -0
- package/internal/runner/herdr/herdrcli/testdata/error-not-git-worktree.json +1 -0
- package/internal/runner/herdr/herdrcli/testdata/error-pane-not-found.json +1 -0
- package/internal/runner/herdr/herdrcli/testdata/error-workspace-not-found.json +1 -0
- package/internal/runner/herdr/herdrcli/testdata/error-worktree-not-found.json +1 -0
- package/internal/runner/herdr/herdrcli/testdata/malformed.json +1 -0
- package/internal/runner/herdr/herdrcli/testdata/pane-close.json +6 -0
- package/internal/runner/herdr/herdrcli/testdata/pane-get.json +25 -0
- package/internal/runner/herdr/herdrcli/testdata/pane-list.json +45 -0
- package/internal/runner/herdr/herdrcli/testdata/pane-process-info-shell.json +22 -0
- package/internal/runner/herdr/herdrcli/testdata/pane-process-info.json +23 -0
- package/internal/runner/herdr/herdrcli/testdata/pane-rename.json +23 -0
- package/internal/runner/herdr/herdrcli/testdata/snapshot.json +213 -0
- package/internal/runner/herdr/herdrcli/testdata/strict-herdr.sh +175 -0
- package/internal/runner/herdr/herdrcli/testdata/tab-create.json +31 -0
- package/internal/runner/herdr/herdrcli/testdata/tab-list.json +26 -0
- package/internal/runner/herdr/herdrcli/testdata/workspace-close.json +6 -0
- package/internal/runner/herdr/herdrcli/testdata/worktree-create.json +58 -0
- package/internal/runner/herdr/herdrcli/testdata/worktree-list.json +35 -0
- package/internal/runner/herdr/herdrcli/testdata/worktree-open.json +59 -0
- package/internal/runner/orca/orca.go +33 -0
- package/internal/runner/orca/orca_test.go +33 -4
- package/internal/runner/runner.go +8 -0
- package/internal/task/beads/beads.go +0 -16
- package/internal/task/beads/config_compatibility_test.go +7 -8
- package/internal/task/jira/filters_test.go +32 -6
- package/internal/task/jira/jira.go +27 -17
- 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-
|
|
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: [
|
|
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)
|
|
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.
|
|
288
|
-
adapters
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
+
}
|
package/cmd/relay-flow/main.go
CHANGED
|
@@ -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/
|
|
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 :=
|
|
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
|
|
229
|
-
//
|
|
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{
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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 :=
|
|
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
|
-
|
|
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,
|
|
347
|
-
groups := make([]*huh.Group, 0,
|
|
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
|
-
|
|
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()
|