relay-flow 0.2.2-alpha → 0.2.4-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 +154 -13
- package/cmd/relay-flow/commands_test.go +5 -1
- package/cmd/relay-flow/main.go +27 -1
- package/cmd/relay-flow/pi_wiring_test.go +64 -0
- package/cmd/relay-flow/serve.go +14 -0
- package/examples/beads-workflow.yaml +3 -3
- package/examples/config-reference.yaml +144 -0
- package/examples/minimal-beads-task-workflow.yaml +34 -0
- package/examples/minimal-jira-task-workflow.yaml +68 -0
- package/examples/workflow-reference.yaml +111 -0
- package/internal/execution/goworkflows/activities.go +19 -0
- package/internal/execution/goworkflows/engine.go +20 -0
- package/internal/execution/goworkflows/engine_test.go +1 -1
- package/internal/execution/goworkflows/fakes_test.go +34 -6
- package/internal/execution/goworkflows/interpreter.go +48 -1
- package/internal/execution/goworkflows/projection.go +52 -11
- package/internal/execution/goworkflows/recovery_test.go +129 -0
- package/internal/harness/opencode/opencode.go +4 -4
- package/internal/harness/opencode/opencode_test.go +59 -4
- package/internal/harness/opencode/repo_setup.go +42 -2
- package/internal/harness/pi/config_test.go +46 -0
- package/internal/harness/pi/lifecycle_test.go +69 -0
- package/internal/harness/pi/pi.go +261 -0
- package/internal/harness/pi/pi_test.go +322 -0
- package/internal/harness/pi/prompt_test.go +121 -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 +168 -0
- package/internal/identity/identity.go +28 -1
- package/internal/identity/identity_test.go +40 -0
- package/internal/run/manager.go +193 -25
- package/internal/run/run.go +24 -6
- package/internal/run/run_manager_test.go +100 -0
- package/internal/runner/herdr/baseref.go +60 -0
- package/internal/runner/herdr/herdr.go +578 -0
- package/internal/runner/herdr/herdr_test.go +688 -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/server/api_test.go +54 -0
- package/internal/server/client.go +11 -0
- package/internal/server/fixture_test.go +18 -0
- package/internal/server/server.go +16 -0
- package/internal/task/beads/beads.go +16 -16
- package/internal/task/beads/config_compatibility_test.go +7 -8
- package/internal/task/beads/status_compatibility_test.go +43 -0
- package/internal/task/jira/filters_test.go +32 -6
- package/internal/task/jira/helpers_test.go +3 -0
- package/internal/task/jira/jira.go +43 -17
- package/internal/task/jira/transition_defaults_test.go +43 -0
- package/internal/task/task.go +8 -0
- package/package.json +1 -1
|
@@ -30,7 +30,7 @@ Use the {{taskSystem}} tools to read the parent ticket {{ticket}}.
|
|
|
30
30
|
|
|
31
31
|
Your mailbox is {{mailbox}}. Read its description and comments for node instructions and feedback.`
|
|
32
32
|
defaultFeedbackPrompt = `New feedback was added to the comments section of your mailbox subtask {{mailbox}}. Read it.`
|
|
33
|
-
defaultHITLPrompt = `
|
|
33
|
+
defaultHITLPrompt = `Return the complete report directly. Relay-flow will show a native TUI approval dialog after the report is valid. Do not use OpenCode's Question tool for relay-flow approval.`
|
|
34
34
|
)
|
|
35
35
|
|
|
36
36
|
var promptVarPattern = regexp.MustCompile(`\{\{([^{}]*)\}\}`)
|
|
@@ -133,9 +133,9 @@ func (h *Harness) FindSession(context.Context, string, string) (harness.Session,
|
|
|
133
133
|
return harness.Session{}, false, nil
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
-
// RenderPrompt renders the selected session prompt, appends
|
|
137
|
-
// instructions for HITL nodes, then renders and appends the node's
|
|
138
|
-
// template.
|
|
136
|
+
// RenderPrompt renders the selected session prompt, appends the harness-owned
|
|
137
|
+
// HITL/TUI instructions for HITL nodes, then renders and appends the node's
|
|
138
|
+
// nudge template.
|
|
139
139
|
func (h *Harness) RenderPrompt(kind harness.PromptKind, data harness.PromptData, nudgeTemplate string) (string, error) {
|
|
140
140
|
var tmpl string
|
|
141
141
|
switch kind {
|
|
@@ -14,7 +14,7 @@ import (
|
|
|
14
14
|
"github.com/rajpopat27/relay-flow/internal/workflow"
|
|
15
15
|
)
|
|
16
16
|
|
|
17
|
-
const configuredPlugin = "relay-flow-plugin@0.2.
|
|
17
|
+
const configuredPlugin = "relay-flow-plugin@0.2.4-alpha"
|
|
18
18
|
|
|
19
19
|
func TestBuildCommandArgv(t *testing.T) {
|
|
20
20
|
t.Setenv("RELAY_FLOW_HOME", "/var/lib/relay-flow-test")
|
|
@@ -61,7 +61,6 @@ func TestRenderPromptTemplatesExposeAllValues(t *testing.T) {
|
|
|
61
61
|
raw := config.RawValues{
|
|
62
62
|
"initial": "initial {{taskSystem}}|{{ticket}}|{{workflow}}|{{repo}}|{{node}}|{{nodeType}}|{{agent}}|{{nodeDescription}}|{{nextSteps}}|{{mailbox}}",
|
|
63
63
|
"feedback": "feedback {{mailbox}}",
|
|
64
|
-
"hitl": "hitl {{node}}",
|
|
65
64
|
}
|
|
66
65
|
h, err := harness.New("opencode", raw)
|
|
67
66
|
if err != nil {
|
|
@@ -77,7 +76,7 @@ func TestRenderPromptTemplatesExposeAllValues(t *testing.T) {
|
|
|
77
76
|
if err != nil {
|
|
78
77
|
t.Fatal(err)
|
|
79
78
|
}
|
|
80
|
-
wantInitial := "initial linear|PAY-101|basicFlow|payments|review|hitl|build|Review it.|end (when: approved)|PAY-234\n\
|
|
79
|
+
wantInitial := "initial linear|PAY-101|basicFlow|payments|review|hitl|build|Review it.|end (when: approved)|PAY-234\n\nReturn the complete report directly. Relay-flow will show a native TUI approval dialog after the report is valid. Do not use OpenCode's Question tool for relay-flow approval.\n\nnudge linear|PAY-101|basicFlow|payments|review|end (when: approved)"
|
|
81
80
|
if initial != wantInitial {
|
|
82
81
|
t.Fatalf("initial prompt = %q, want %q", initial, wantInitial)
|
|
83
82
|
}
|
|
@@ -85,7 +84,7 @@ func TestRenderPromptTemplatesExposeAllValues(t *testing.T) {
|
|
|
85
84
|
if err != nil {
|
|
86
85
|
t.Fatal(err)
|
|
87
86
|
}
|
|
88
|
-
if want := "feedback PAY-234\n\
|
|
87
|
+
if want := "feedback PAY-234\n\nReturn the complete report directly. Relay-flow will show a native TUI approval dialog after the report is valid. Do not use OpenCode's Question tool for relay-flow approval.\n\nnudge linear|PAY-101|basicFlow|payments|review|end (when: approved)"; feedback != want {
|
|
89
88
|
t.Fatalf("feedback prompt = %q, want %q", feedback, want)
|
|
90
89
|
}
|
|
91
90
|
}
|
|
@@ -206,6 +205,62 @@ func TestSetupRepoAddsPluginPropertyToJSONCWithComments(t *testing.T) {
|
|
|
206
205
|
}
|
|
207
206
|
}
|
|
208
207
|
|
|
208
|
+
func TestSetupRepoCreatesOpenCodeTUIConfig(t *testing.T) {
|
|
209
|
+
dir := t.TempDir()
|
|
210
|
+
if err := opencode.New().SetupRepo(t.Context(), dir); err != nil {
|
|
211
|
+
t.Fatalf("SetupRepo: %v", err)
|
|
212
|
+
}
|
|
213
|
+
path := filepath.Join(dir, ".opencode", "tui.json")
|
|
214
|
+
data, err := os.ReadFile(path)
|
|
215
|
+
if err != nil {
|
|
216
|
+
t.Fatal(err)
|
|
217
|
+
}
|
|
218
|
+
var cfg struct {
|
|
219
|
+
Schema string `json:"$schema"`
|
|
220
|
+
Plugin []string `json:"plugin"`
|
|
221
|
+
}
|
|
222
|
+
if err := json.Unmarshal(data, &cfg); err != nil {
|
|
223
|
+
t.Fatalf("created TUI config is invalid JSON: %v\n%s", err, data)
|
|
224
|
+
}
|
|
225
|
+
if cfg.Schema != "https://opencode.ai/tui.json" {
|
|
226
|
+
t.Fatalf("$schema = %q", cfg.Schema)
|
|
227
|
+
}
|
|
228
|
+
if !reflect.DeepEqual(cfg.Plugin, []string{configuredPlugin}) {
|
|
229
|
+
t.Fatalf("plugin = %v", cfg.Plugin)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
func TestSetupRepoUpdatesExistingOpenCodeTUIJSONC(t *testing.T) {
|
|
234
|
+
dir := t.TempDir()
|
|
235
|
+
configDir := filepath.Join(dir, ".opencode")
|
|
236
|
+
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
|
237
|
+
t.Fatal(err)
|
|
238
|
+
}
|
|
239
|
+
path := filepath.Join(configDir, "tui.jsonc")
|
|
240
|
+
original := `{
|
|
241
|
+
// keep the local theme
|
|
242
|
+
"theme": "catppuccin"
|
|
243
|
+
}
|
|
244
|
+
`
|
|
245
|
+
if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
|
|
246
|
+
t.Fatal(err)
|
|
247
|
+
}
|
|
248
|
+
if err := opencode.New().SetupRepo(t.Context(), dir); err != nil {
|
|
249
|
+
t.Fatalf("SetupRepo: %v", err)
|
|
250
|
+
}
|
|
251
|
+
data, err := os.ReadFile(path)
|
|
252
|
+
if err != nil {
|
|
253
|
+
t.Fatal(err)
|
|
254
|
+
}
|
|
255
|
+
text := string(data)
|
|
256
|
+
if !strings.Contains(text, "// keep the local theme") || !strings.Contains(text, configuredPlugin) {
|
|
257
|
+
t.Fatalf("TUI config was not preserved and updated:\n%s", text)
|
|
258
|
+
}
|
|
259
|
+
if _, err := os.Stat(filepath.Join(configDir, "tui.json")); !os.IsNotExist(err) {
|
|
260
|
+
t.Fatalf("unexpected tui.json created: %v", err)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
209
264
|
func setupTwice(t *testing.T, dir string) {
|
|
210
265
|
t.Helper()
|
|
211
266
|
h := opencode.New()
|
|
@@ -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.2.
|
|
13
|
+
const relayFlowPlugin = "relay-flow-plugin@0.2.4-alpha"
|
|
14
14
|
|
|
15
15
|
type jsoncToken struct {
|
|
16
16
|
kind byte
|
|
@@ -47,12 +47,34 @@ func setupRepo(repoPath string) error {
|
|
|
47
47
|
if err != nil {
|
|
48
48
|
return err
|
|
49
49
|
}
|
|
50
|
+
if err := ensurePluginConfig(path, mode, ""); err != nil {
|
|
51
|
+
return err
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
tuiPath, tuiMode, err := openCodeTUIConfigPath(repoPath)
|
|
55
|
+
if err != nil {
|
|
56
|
+
return err
|
|
57
|
+
}
|
|
58
|
+
if err := os.MkdirAll(filepath.Dir(tuiPath), 0o755); err != nil {
|
|
59
|
+
return fmt.Errorf("opencode: create TUI config directory: %w", err)
|
|
60
|
+
}
|
|
61
|
+
if err := ensurePluginConfig(tuiPath, tuiMode, "https://opencode.ai/tui.json"); err != nil {
|
|
62
|
+
return fmt.Errorf("opencode: setup TUI config: %w", err)
|
|
63
|
+
}
|
|
64
|
+
return nil
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
func ensurePluginConfig(path string, mode os.FileMode, schema string) error {
|
|
50
68
|
data, err := os.ReadFile(path)
|
|
51
69
|
if err != nil {
|
|
52
70
|
if !os.IsNotExist(err) {
|
|
53
71
|
return fmt.Errorf("opencode: read %s: %w", path, err)
|
|
54
72
|
}
|
|
55
|
-
|
|
73
|
+
if schema == "" {
|
|
74
|
+
data = []byte("{\n \"plugin\": [\"" + relayFlowPlugin + "\"]\n}\n")
|
|
75
|
+
} else {
|
|
76
|
+
data = []byte("{\n \"$schema\": \"" + schema + "\",\n \"plugin\": [\"" + relayFlowPlugin + "\"]\n}\n")
|
|
77
|
+
}
|
|
56
78
|
} else {
|
|
57
79
|
data, err = updateOpenCodeConfig(data)
|
|
58
80
|
if err != nil {
|
|
@@ -86,6 +108,24 @@ func openCodeConfigPath(repoPath string) (string, os.FileMode, error) {
|
|
|
86
108
|
return jsonPath, 0o644, nil
|
|
87
109
|
}
|
|
88
110
|
|
|
111
|
+
func openCodeTUIConfigPath(repoPath string) (string, os.FileMode, error) {
|
|
112
|
+
dir := filepath.Join(repoPath, ".opencode")
|
|
113
|
+
for _, name := range []string{"tui.json", "tui.jsonc"} {
|
|
114
|
+
path := filepath.Join(dir, name)
|
|
115
|
+
info, err := os.Stat(path)
|
|
116
|
+
if err == nil {
|
|
117
|
+
if !info.Mode().IsRegular() {
|
|
118
|
+
return "", 0, fmt.Errorf("opencode: TUI config %s is not a regular file", path)
|
|
119
|
+
}
|
|
120
|
+
return path, info.Mode().Perm(), nil
|
|
121
|
+
}
|
|
122
|
+
if !os.IsNotExist(err) {
|
|
123
|
+
return "", 0, fmt.Errorf("opencode: inspect TUI config %s: %w", path, err)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return filepath.Join(dir, "tui.json"), 0o644, nil
|
|
127
|
+
}
|
|
128
|
+
|
|
89
129
|
func updateOpenCodeConfig(data []byte) ([]byte, error) {
|
|
90
130
|
tokens, err := tokenizeJSONC(data)
|
|
91
131
|
if err != nil {
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
package pi
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"strings"
|
|
5
|
+
"testing"
|
|
6
|
+
|
|
7
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
8
|
+
"github.com/rajpopat27/relay-flow/internal/harness"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
func TestPiHarnessConfigUsesDocumentedDefaults(t *testing.T) {
|
|
12
|
+
defaults, err := harness.Defaults("pi")
|
|
13
|
+
if err != nil {
|
|
14
|
+
t.Fatalf("harness.Defaults(pi): %v", err)
|
|
15
|
+
}
|
|
16
|
+
if defaults["initial"] != defaultInitialPrompt {
|
|
17
|
+
t.Fatalf("initial default = %#v, want %#v", defaults["initial"], defaultInitialPrompt)
|
|
18
|
+
}
|
|
19
|
+
if defaults["feedback"] != defaultFeedbackPrompt {
|
|
20
|
+
t.Fatalf("feedback default = %#v, want %#v", defaults["feedback"], defaultFeedbackPrompt)
|
|
21
|
+
}
|
|
22
|
+
if _, ok := defaults["hitl"]; ok {
|
|
23
|
+
t.Fatalf("Pi defaults unexpectedly contain a HITL prompt: %#v", defaults["hitl"])
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
func TestPiHarnessConfigRejectsNonTemplateFields(t *testing.T) {
|
|
28
|
+
for _, field := range []string{"hitl", "agent", "model"} {
|
|
29
|
+
t.Run(field, func(t *testing.T) {
|
|
30
|
+
_, err := harness.New("pi", config.RawValues{field: "unsupported"})
|
|
31
|
+
if err == nil {
|
|
32
|
+
t.Fatalf("Pi harness accepted unsupported config field %q", field)
|
|
33
|
+
}
|
|
34
|
+
if !strings.Contains(err.Error(), field) {
|
|
35
|
+
t.Fatalf("error = %q, want field name %q", err, field)
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
func TestPiHarnessConfigRejectsExplicitNullTemplate(t *testing.T) {
|
|
42
|
+
_, err := harness.New("pi", config.RawValues{"initial": nil})
|
|
43
|
+
if err == nil || !strings.Contains(err.Error(), "explicit null") {
|
|
44
|
+
t.Fatalf("explicit null template error = %v", err)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
package pi
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"os"
|
|
6
|
+
"path/filepath"
|
|
7
|
+
"reflect"
|
|
8
|
+
"testing"
|
|
9
|
+
|
|
10
|
+
"github.com/rajpopat27/relay-flow/internal/harness"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
func TestPiSetupRepoIsSideEffectFree(t *testing.T) {
|
|
14
|
+
repoPath := t.TempDir()
|
|
15
|
+
sentinelPath := filepath.Join(repoPath, "existing.txt")
|
|
16
|
+
sentinel := []byte("leave this repository unchanged")
|
|
17
|
+
if err := os.WriteFile(sentinelPath, sentinel, 0o640); err != nil {
|
|
18
|
+
t.Fatal(err)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
h := newPiHarness(t)
|
|
22
|
+
if err := h.SetupRepo(context.Background(), repoPath); err != nil {
|
|
23
|
+
t.Fatalf("SetupRepo: %v", err)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
data, err := os.ReadFile(sentinelPath)
|
|
27
|
+
if err != nil {
|
|
28
|
+
t.Fatalf("read sentinel: %v", err)
|
|
29
|
+
}
|
|
30
|
+
if !reflect.DeepEqual(data, sentinel) {
|
|
31
|
+
t.Fatalf("SetupRepo changed existing repository content: %q", data)
|
|
32
|
+
}
|
|
33
|
+
for _, name := range []string{"opencode.json", "opencode.jsonc", ".pi"} {
|
|
34
|
+
if _, err := os.Stat(filepath.Join(repoPath, name)); !os.IsNotExist(err) {
|
|
35
|
+
t.Fatalf("SetupRepo created %q: %v", name, err)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
func TestPiFindSessionDoesNotDiscoverByTitle(t *testing.T) {
|
|
41
|
+
missingRepo := filepath.Join(t.TempDir(), "not-a-repository")
|
|
42
|
+
h := newPiHarness(t)
|
|
43
|
+
|
|
44
|
+
session, ok, err := h.FindSession(context.Background(), missingRepo, "PAY-101:implement")
|
|
45
|
+
if err != nil {
|
|
46
|
+
t.Fatalf("FindSession: %v", err)
|
|
47
|
+
}
|
|
48
|
+
if ok {
|
|
49
|
+
t.Fatalf("FindSession found an unrequested session: %+v", session)
|
|
50
|
+
}
|
|
51
|
+
if session != (harness.Session{}) {
|
|
52
|
+
t.Fatalf("FindSession returned session data: %+v", session)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
func TestPiResumeUsesOnlyPersistedLaunchSpecSessionID(t *testing.T) {
|
|
57
|
+
spec := launchSpec(t)
|
|
58
|
+
spec.RepoPath = filepath.Join(t.TempDir(), "missing-ticket-environment")
|
|
59
|
+
spec.ResumeID = "session-123"
|
|
60
|
+
|
|
61
|
+
cmd, err := newPiHarness(t).BuildCommand(spec)
|
|
62
|
+
if err != nil {
|
|
63
|
+
t.Fatalf("BuildCommand: %v", err)
|
|
64
|
+
}
|
|
65
|
+
want := []string{"--name", spec.Title, "--session-id", spec.ResumeID, spec.Prompt}
|
|
66
|
+
if !reflect.DeepEqual(cmd.Args, want) {
|
|
67
|
+
t.Fatalf("Args = %#v, want %#v", cmd.Args, want)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// Package pi is the built-in launch-time Harness adapter for the Pi coding
|
|
2
|
+
// agent. Pi supplies one built-in coding agent, represented by the logical
|
|
3
|
+
// relay-flow agent name "default", plus repository-owned role prompts under
|
|
4
|
+
// .pi/roles. The runtime extension is installed by the user and owns report
|
|
5
|
+
// parsing and delivery.
|
|
6
|
+
package pi
|
|
7
|
+
|
|
8
|
+
import (
|
|
9
|
+
"context"
|
|
10
|
+
"encoding/json"
|
|
11
|
+
"fmt"
|
|
12
|
+
"os"
|
|
13
|
+
"os/exec"
|
|
14
|
+
"path/filepath"
|
|
15
|
+
"regexp"
|
|
16
|
+
"strings"
|
|
17
|
+
|
|
18
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
19
|
+
"github.com/rajpopat27/relay-flow/internal/harness"
|
|
20
|
+
"github.com/rajpopat27/relay-flow/internal/runner"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
const (
|
|
24
|
+
defaultInitialPrompt = `Task system: {{taskSystem}}
|
|
25
|
+
Use the {{taskSystem}} tools to read the parent ticket {{ticket}}.
|
|
26
|
+
|
|
27
|
+
Your mailbox is {{mailbox}}. Read its description and comments for node instructions and feedback.`
|
|
28
|
+
defaultFeedbackPrompt = `New feedback was added to the comments section of your mailbox subtask {{mailbox}}. Read it.`
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
var promptVarPattern = regexp.MustCompile(`\{\{([^{}]*)\}\}`)
|
|
32
|
+
|
|
33
|
+
var knownPromptVars = map[string]bool{
|
|
34
|
+
"taskSystem": true, "ticket": true, "workflow": true, "repo": true,
|
|
35
|
+
"node": true, "nodeType": true, "agent": true, "nodeDescription": true,
|
|
36
|
+
"nextSteps": true, "mailbox": true,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Config is the adapter-owned root harnessConfig. Pi exposes no harness
|
|
40
|
+
// prompt for HITL approval because approval is performed through Pi's host
|
|
41
|
+
// UI by the runtime extension.
|
|
42
|
+
type Config struct {
|
|
43
|
+
Initial string `yaml:"initial"`
|
|
44
|
+
Feedback string `yaml:"feedback"`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// DefaultConfig is written by relay-flow init and fills omitted values when
|
|
48
|
+
// configuration is loaded through the harness factory.
|
|
49
|
+
func DefaultConfig() config.RawValues {
|
|
50
|
+
return config.RawValues{
|
|
51
|
+
"initial": defaultInitialPrompt,
|
|
52
|
+
"feedback": defaultFeedbackPrompt,
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
func init() {
|
|
57
|
+
harness.Register("pi", harness.Factory{
|
|
58
|
+
DefaultConfig: DefaultConfig,
|
|
59
|
+
New: func(raw config.RawValues) (harness.Harness, error) {
|
|
60
|
+
var cfg Config
|
|
61
|
+
if err := config.DecodeStrict(raw, &cfg); err != nil {
|
|
62
|
+
return nil, fmt.Errorf("pi harnessConfig: %w", err)
|
|
63
|
+
}
|
|
64
|
+
for name, tmpl := range map[string]string{
|
|
65
|
+
"initial": cfg.Initial,
|
|
66
|
+
"feedback": cfg.Feedback,
|
|
67
|
+
} {
|
|
68
|
+
if err := validateTemplate(tmpl); err != nil {
|
|
69
|
+
return nil, fmt.Errorf("pi harnessConfig templates.%s: %w", name, err)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return New(cfg), nil
|
|
73
|
+
},
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Harness implements harness.Harness for Pi.
|
|
78
|
+
type Harness struct {
|
|
79
|
+
templates Config
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// New returns the production Pi harness. The no-argument form uses the
|
|
83
|
+
// adapter defaults; factory construction supplies an explicitly decoded
|
|
84
|
+
// configuration.
|
|
85
|
+
func New(cfg ...Config) *Harness {
|
|
86
|
+
if len(cfg) > 0 {
|
|
87
|
+
return &Harness{templates: cfg[0]}
|
|
88
|
+
}
|
|
89
|
+
return &Harness{templates: Config{
|
|
90
|
+
Initial: defaultInitialPrompt,
|
|
91
|
+
Feedback: defaultFeedbackPrompt,
|
|
92
|
+
}}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// SetupRepo is intentionally a no-op. The relay-flow Pi runtime extension is
|
|
96
|
+
// installed manually in Pi's global package settings rather than configured
|
|
97
|
+
// in each repository.
|
|
98
|
+
func (*Harness) SetupRepo(context.Context, string) error { return nil }
|
|
99
|
+
|
|
100
|
+
// ValidateAgent accepts Pi's built-in logical agent or a repository role
|
|
101
|
+
// prompt. Pi has no named-agent listing API, so custom roles are verified by
|
|
102
|
+
// checking for a readable, non-empty .pi/roles/<agent>.md file.
|
|
103
|
+
func (*Harness) ValidateAgent(_ context.Context, repoPath, agent string) error {
|
|
104
|
+
if _, err := resolveRolePrompt(repoPath, agent); err != nil {
|
|
105
|
+
return err
|
|
106
|
+
}
|
|
107
|
+
if _, err := exec.LookPath("pi"); err != nil {
|
|
108
|
+
return fmt.Errorf("pi: executable unavailable: %w", err)
|
|
109
|
+
}
|
|
110
|
+
return nil
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
func resolveRolePrompt(repoPath, agent string) (string, error) {
|
|
114
|
+
if agent == "default" {
|
|
115
|
+
return "", nil
|
|
116
|
+
}
|
|
117
|
+
if strings.TrimSpace(agent) == "" || strings.TrimSpace(agent) != agent || strings.ContainsAny(agent, `/\\`) || agent == "." || agent == ".." {
|
|
118
|
+
return "", fmt.Errorf("pi: invalid role %q", agent)
|
|
119
|
+
}
|
|
120
|
+
if repoPath == "" {
|
|
121
|
+
return "", fmt.Errorf("pi: role %q requires a repository path", agent)
|
|
122
|
+
}
|
|
123
|
+
root, err := filepath.Abs(repoPath)
|
|
124
|
+
if err != nil {
|
|
125
|
+
return "", fmt.Errorf("pi: resolve repository path for role %q: %w", agent, err)
|
|
126
|
+
}
|
|
127
|
+
path := filepath.Join(root, ".pi", "roles", agent+".md")
|
|
128
|
+
info, err := os.Stat(path)
|
|
129
|
+
if err != nil {
|
|
130
|
+
if os.IsNotExist(err) {
|
|
131
|
+
return "", fmt.Errorf("pi: role %q is unavailable; expected %s", agent, path)
|
|
132
|
+
}
|
|
133
|
+
return "", fmt.Errorf("pi: inspect role %q: %w", agent, err)
|
|
134
|
+
}
|
|
135
|
+
if !info.Mode().IsRegular() {
|
|
136
|
+
return "", fmt.Errorf("pi: role %q is not a regular file: %s", agent, path)
|
|
137
|
+
}
|
|
138
|
+
contents, err := os.ReadFile(path)
|
|
139
|
+
if err != nil {
|
|
140
|
+
return "", fmt.Errorf("pi: read role %q: %w", agent, err)
|
|
141
|
+
}
|
|
142
|
+
if strings.TrimSpace(string(contents)) == "" {
|
|
143
|
+
return "", fmt.Errorf("pi: role %q is empty: %s", agent, path)
|
|
144
|
+
}
|
|
145
|
+
return path, nil
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// FindSession is intentionally discovery-free. Normal execution resumes only
|
|
149
|
+
// the Pi session ID persisted by runtime registration.
|
|
150
|
+
func (*Harness) FindSession(context.Context, string, string) (harness.Session, bool, error) {
|
|
151
|
+
return harness.Session{}, false, nil
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// RenderPrompt renders the selected initial or feedback template and the
|
|
155
|
+
// node's nudge template. HITL approval is not encoded in the prompt; the Pi
|
|
156
|
+
// extension asks for approval through ctx.ui.select.
|
|
157
|
+
func (h *Harness) RenderPrompt(kind harness.PromptKind, data harness.PromptData, nudgeTemplate string) (string, error) {
|
|
158
|
+
var tmpl string
|
|
159
|
+
switch kind {
|
|
160
|
+
case harness.PromptInitial:
|
|
161
|
+
tmpl = h.templates.Initial
|
|
162
|
+
case harness.PromptFeedback:
|
|
163
|
+
tmpl = h.templates.Feedback
|
|
164
|
+
default:
|
|
165
|
+
return "", fmt.Errorf("pi: unknown prompt kind %q", kind)
|
|
166
|
+
}
|
|
167
|
+
return appendPrompt(renderTemplate(tmpl, data), renderTemplate(nudgeTemplate, data)), nil
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// BuildCommand returns the interactive Pi invocation. The runner supplies a
|
|
171
|
+
// PTY for Pi's stdin/stdout; the rendered prompt is the final positional argv
|
|
172
|
+
// value. A custom role adds Pi's --append-system-prompt option with the role
|
|
173
|
+
// file from the registered repository. Pi 0.84.1 rejects a bare -- terminator,
|
|
174
|
+
// so none is included. A non-empty ResumeID selects Pi's exact session-id
|
|
175
|
+
// resume option.
|
|
176
|
+
func (*Harness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error) {
|
|
177
|
+
rolePath, err := resolveRolePrompt(spec.RepoPath, spec.Agent)
|
|
178
|
+
if err != nil {
|
|
179
|
+
return runner.Command{}, err
|
|
180
|
+
}
|
|
181
|
+
nextSteps, err := json.Marshal(spec.NextSteps)
|
|
182
|
+
if err != nil {
|
|
183
|
+
return runner.Command{}, fmt.Errorf("pi: marshal next steps: %w", err)
|
|
184
|
+
}
|
|
185
|
+
root, err := relayFlowHome()
|
|
186
|
+
if err != nil {
|
|
187
|
+
return runner.Command{}, err
|
|
188
|
+
}
|
|
189
|
+
env := map[string]string{
|
|
190
|
+
"RELAY_FLOW_HOME": root,
|
|
191
|
+
"RELAY_FLOW_RUN_ID": string(spec.RunID),
|
|
192
|
+
"RELAY_FLOW_WORKFLOW": spec.Workflow,
|
|
193
|
+
"RELAY_FLOW_REPO": spec.RepoName,
|
|
194
|
+
"RELAY_FLOW_TICKET": spec.Ticket,
|
|
195
|
+
"RELAY_FLOW_NODE": spec.Node,
|
|
196
|
+
"RELAY_FLOW_NODE_TYPE": string(spec.NodeType),
|
|
197
|
+
"RELAY_FLOW_NUDGE_PROMPT": spec.NudgePrompt,
|
|
198
|
+
"RELAY_FLOW_NEXT_STEPS_JSON": string(nextSteps),
|
|
199
|
+
}
|
|
200
|
+
args := []string{"--name", spec.Title}
|
|
201
|
+
if rolePath != "" {
|
|
202
|
+
args = append(args, "--append-system-prompt", rolePath)
|
|
203
|
+
}
|
|
204
|
+
if spec.ResumeID != "" {
|
|
205
|
+
args = append(args, "--session-id", spec.ResumeID)
|
|
206
|
+
}
|
|
207
|
+
args = append(args, spec.Prompt)
|
|
208
|
+
return runner.Command{
|
|
209
|
+
Executable: "pi",
|
|
210
|
+
Args: args,
|
|
211
|
+
Env: env,
|
|
212
|
+
}, nil
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
func relayFlowHome() (string, error) {
|
|
216
|
+
if root := os.Getenv("RELAY_FLOW_HOME"); root != "" {
|
|
217
|
+
return root, nil
|
|
218
|
+
}
|
|
219
|
+
home, err := os.UserHomeDir()
|
|
220
|
+
if err != nil {
|
|
221
|
+
return "", fmt.Errorf("pi: resolve relay-flow home: %w", err)
|
|
222
|
+
}
|
|
223
|
+
return filepath.Join(home, ".relay-flow"), nil
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
func validateTemplate(tmpl string) error {
|
|
227
|
+
for _, match := range promptVarPattern.FindAllStringSubmatch(tmpl, -1) {
|
|
228
|
+
if !knownPromptVars[match[1]] {
|
|
229
|
+
return fmt.Errorf("unknown template variable {{%s}}", match[1])
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return nil
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
func renderTemplate(tmpl string, data harness.PromptData) string {
|
|
236
|
+
values := map[string]string{
|
|
237
|
+
"taskSystem": data.TaskSystem,
|
|
238
|
+
"ticket": data.Ticket,
|
|
239
|
+
"workflow": data.Workflow,
|
|
240
|
+
"repo": data.Repo,
|
|
241
|
+
"node": data.Node,
|
|
242
|
+
"nodeType": string(data.NodeType),
|
|
243
|
+
"agent": data.Agent,
|
|
244
|
+
"nodeDescription": data.NodeDescription,
|
|
245
|
+
"nextSteps": data.NextSteps,
|
|
246
|
+
"mailbox": data.Mailbox,
|
|
247
|
+
}
|
|
248
|
+
return promptVarPattern.ReplaceAllStringFunc(tmpl, func(match string) string {
|
|
249
|
+
return values[promptVarPattern.FindStringSubmatch(match)[1]]
|
|
250
|
+
})
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
func appendPrompt(prompt, extra string) string {
|
|
254
|
+
if extra == "" {
|
|
255
|
+
return prompt
|
|
256
|
+
}
|
|
257
|
+
if prompt == "" {
|
|
258
|
+
return extra
|
|
259
|
+
}
|
|
260
|
+
return prompt + "\n\n" + extra
|
|
261
|
+
}
|