relay-flow 0.2.3-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 +116 -10
- package/cmd/relay-flow/main.go +2 -0
- package/cmd/relay-flow/pi_wiring_test.go +64 -0
- package/cmd/relay-flow/serve.go +2 -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/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 +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/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/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
|
@@ -0,0 +1,121 @@
|
|
|
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
|
+
"github.com/rajpopat27/relay-flow/internal/workflow"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
func TestPiRenderPromptSubstitutesInitialAndFeedbackData(t *testing.T) {
|
|
13
|
+
h := newPiHarness(t)
|
|
14
|
+
data := harness.PromptData{
|
|
15
|
+
TaskSystem: "jira",
|
|
16
|
+
Ticket: "PAY-101",
|
|
17
|
+
Workflow: "basicFlow",
|
|
18
|
+
Repo: "payments",
|
|
19
|
+
Node: "implement",
|
|
20
|
+
NodeType: workflow.NodeAgent,
|
|
21
|
+
Agent: "default",
|
|
22
|
+
NodeDescription: "Implement the requested change.",
|
|
23
|
+
NextSteps: "review (when: ready)",
|
|
24
|
+
Mailbox: "PAY-234",
|
|
25
|
+
}
|
|
26
|
+
nudge := "nudge {{taskSystem}}|{{ticket}}|{{workflow}}|{{repo}}|{{node}}|{{nextSteps}}"
|
|
27
|
+
|
|
28
|
+
initial, err := h.RenderPrompt(harness.PromptInitial, data, nudge)
|
|
29
|
+
if err != nil {
|
|
30
|
+
t.Fatalf("RenderPrompt(initial): %v", err)
|
|
31
|
+
}
|
|
32
|
+
wantInitial := "Task system: jira\nUse the jira tools to read the parent ticket PAY-101.\n\nYour mailbox is PAY-234. Read its description and comments for node instructions and feedback.\n\nnudge jira|PAY-101|basicFlow|payments|implement|review (when: ready)"
|
|
33
|
+
if initial != wantInitial {
|
|
34
|
+
t.Fatalf("initial prompt = %q, want %q", initial, wantInitial)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
feedback, err := h.RenderPrompt(harness.PromptFeedback, data, nudge)
|
|
38
|
+
if err != nil {
|
|
39
|
+
t.Fatalf("RenderPrompt(feedback): %v", err)
|
|
40
|
+
}
|
|
41
|
+
wantFeedback := "New feedback was added to the comments section of your mailbox subtask PAY-234. Read it.\n\nnudge jira|PAY-101|basicFlow|payments|implement|review (when: ready)"
|
|
42
|
+
if feedback != wantFeedback {
|
|
43
|
+
t.Fatalf("feedback prompt = %q, want %q", feedback, wantFeedback)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
func TestPiRenderPromptHonorsNodeNudgeTimingInput(t *testing.T) {
|
|
48
|
+
h, err := harness.New("pi", config.RawValues{
|
|
49
|
+
"initial": "initial {{node}}",
|
|
50
|
+
"feedback": "feedback {{node}}",
|
|
51
|
+
})
|
|
52
|
+
if err != nil {
|
|
53
|
+
t.Fatalf("harness.New(pi): %v", err)
|
|
54
|
+
}
|
|
55
|
+
data := harness.PromptData{Node: "implement", Agent: "default"}
|
|
56
|
+
|
|
57
|
+
for _, tt := range []struct {
|
|
58
|
+
name string
|
|
59
|
+
kind harness.PromptKind
|
|
60
|
+
want string
|
|
61
|
+
nudge string
|
|
62
|
+
}{
|
|
63
|
+
{name: "initial without nudge", kind: harness.PromptInitial, want: "initial implement"},
|
|
64
|
+
{name: "initial with nudge", kind: harness.PromptInitial, want: "initial implement\n\nnudge implement", nudge: "nudge {{node}}"},
|
|
65
|
+
{name: "feedback without nudge", kind: harness.PromptFeedback, want: "feedback implement"},
|
|
66
|
+
{name: "feedback with nudge", kind: harness.PromptFeedback, want: "feedback implement\n\nnudge implement", nudge: "nudge {{node}}"},
|
|
67
|
+
} {
|
|
68
|
+
t.Run(tt.name, func(t *testing.T) {
|
|
69
|
+
got, err := h.RenderPrompt(tt.kind, data, tt.nudge)
|
|
70
|
+
if err != nil {
|
|
71
|
+
t.Fatalf("RenderPrompt: %v", err)
|
|
72
|
+
}
|
|
73
|
+
if got != tt.want {
|
|
74
|
+
t.Fatalf("prompt = %q, want %q", got, tt.want)
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
func TestPiPromptRetainsDefaultAgentLabel(t *testing.T) {
|
|
81
|
+
h, err := harness.New("pi", config.RawValues{
|
|
82
|
+
"initial": "agent={{agent}} node={{node}}",
|
|
83
|
+
"feedback": "feedback {{agent}}",
|
|
84
|
+
})
|
|
85
|
+
if err != nil {
|
|
86
|
+
t.Fatalf("harness.New(pi): %v", err)
|
|
87
|
+
}
|
|
88
|
+
got, err := h.RenderPrompt(harness.PromptInitial, harness.PromptData{
|
|
89
|
+
Agent: "default",
|
|
90
|
+
Node: "implement",
|
|
91
|
+
}, "")
|
|
92
|
+
if err != nil {
|
|
93
|
+
t.Fatalf("RenderPrompt: %v", err)
|
|
94
|
+
}
|
|
95
|
+
if got != "agent=default node=implement" {
|
|
96
|
+
t.Fatalf("prompt = %q, want default agent label", got)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
func TestPiPromptsDoNotIncludeOpenCodeQuestionInstructions(t *testing.T) {
|
|
101
|
+
h := newPiHarness(t)
|
|
102
|
+
data := harness.PromptData{
|
|
103
|
+
TaskSystem: "jira",
|
|
104
|
+
Ticket: "PAY-101",
|
|
105
|
+
Node: "review",
|
|
106
|
+
NodeType: workflow.NodeHITL,
|
|
107
|
+
Agent: "default",
|
|
108
|
+
Mailbox: "PAY-235",
|
|
109
|
+
}
|
|
110
|
+
for _, kind := range []harness.PromptKind{harness.PromptInitial, harness.PromptFeedback} {
|
|
111
|
+
got, err := h.RenderPrompt(kind, data, "")
|
|
112
|
+
if err != nil {
|
|
113
|
+
t.Fatalf("RenderPrompt(%q): %v", kind, err)
|
|
114
|
+
}
|
|
115
|
+
for _, forbidden := range []string{"OpenCode", "Question tool", "Approve", "Reject"} {
|
|
116
|
+
if strings.Contains(got, forbidden) {
|
|
117
|
+
t.Fatalf("%q prompt contains OpenCode HITL instruction %q: %q", kind, forbidden, got)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
{
|
|
2
|
+
"capturedAt": "2026-08-31T18:34Z",
|
|
3
|
+
"version": "0.84.1",
|
|
4
|
+
"command": "/home/linuxbrew/.linuxbrew/Cellar/pi-coding-agent/0.84.1/bin/pi",
|
|
5
|
+
"cwd": "/tmp/pi-live-contract/project",
|
|
6
|
+
"note": "Executed live against installed Pi 0.84.1 with a real provider (github-copilot). Interactive runs used script(1) to supply a PTY. The smoke runs added --session-dir, --model, --thinking, -nt and -e for isolation/observability; the production launch shape itself is argv[0..] below and was validated by the rejection cases in errors.",
|
|
7
|
+
"argv": {
|
|
8
|
+
"production": {
|
|
9
|
+
"fresh": [
|
|
10
|
+
"pi",
|
|
11
|
+
"--name",
|
|
12
|
+
"PAY-101:implement",
|
|
13
|
+
"<rendered prompt as one positional argument>"
|
|
14
|
+
],
|
|
15
|
+
"resume": [
|
|
16
|
+
"pi",
|
|
17
|
+
"--name",
|
|
18
|
+
"PAY-101:implement",
|
|
19
|
+
"--session-id",
|
|
20
|
+
"<persisted session id>",
|
|
21
|
+
"<rendered prompt as one positional argument>"
|
|
22
|
+
]
|
|
23
|
+
},
|
|
24
|
+
"smokeObserved": [
|
|
25
|
+
"/home/linuxbrew/.linuxbrew/Cellar/node/26.7.0/bin/node",
|
|
26
|
+
"/home/linuxbrew/.linuxbrew/Cellar/pi-coding-agent/0.84.1/libexec/bin/pi",
|
|
27
|
+
"--name",
|
|
28
|
+
"PAY-101:implement",
|
|
29
|
+
"--session-dir",
|
|
30
|
+
"/tmp/pi-live-contract/sessions",
|
|
31
|
+
"--model",
|
|
32
|
+
"github-copilot/claude-haiku-4.5",
|
|
33
|
+
"--thinking",
|
|
34
|
+
"off",
|
|
35
|
+
"-nt",
|
|
36
|
+
"-e",
|
|
37
|
+
"<extension path>",
|
|
38
|
+
"<prompt>"
|
|
39
|
+
]
|
|
40
|
+
},
|
|
41
|
+
"interactive": {
|
|
42
|
+
"stdinIsTTY": true,
|
|
43
|
+
"stdoutIsTTY": true,
|
|
44
|
+
"mode": "tui",
|
|
45
|
+
"hasUI": true,
|
|
46
|
+
"processAliveAfterSettled": true,
|
|
47
|
+
"observedAliveSecondsAfterSettle": 65,
|
|
48
|
+
"terminalTitle": "\u03c0 - PAY-101:review - project",
|
|
49
|
+
"footer": "/tmp/pi-live-contract/project \u2022 PAY-101:review"
|
|
50
|
+
},
|
|
51
|
+
"nonInteractive": {
|
|
52
|
+
"stdinIsTTY": false,
|
|
53
|
+
"stdoutIsTTY": false,
|
|
54
|
+
"mode": "print",
|
|
55
|
+
"hasUI": false,
|
|
56
|
+
"exitCode": 0,
|
|
57
|
+
"processExitsAfterResponse": true
|
|
58
|
+
},
|
|
59
|
+
"environment": {
|
|
60
|
+
"childCwd": "/tmp/pi-live-contract/project",
|
|
61
|
+
"relayFlowVariablesVisibleToExtension": [
|
|
62
|
+
"RELAY_FLOW_HOME",
|
|
63
|
+
"RELAY_FLOW_RUN_ID",
|
|
64
|
+
"RELAY_FLOW_WORKFLOW",
|
|
65
|
+
"RELAY_FLOW_REPO",
|
|
66
|
+
"RELAY_FLOW_TICKET",
|
|
67
|
+
"RELAY_FLOW_NODE",
|
|
68
|
+
"RELAY_FLOW_NODE_TYPE",
|
|
69
|
+
"RELAY_FLOW_NUDGE_PROMPT",
|
|
70
|
+
"RELAY_FLOW_NEXT_STEPS_JSON"
|
|
71
|
+
]
|
|
72
|
+
},
|
|
73
|
+
"session": {
|
|
74
|
+
"fresh": {
|
|
75
|
+
"id": "01a0591c-3747-7075-9bb7-cc89a0f65dcd",
|
|
76
|
+
"file": "/tmp/pi-live-contract/sessions/2026-08-31T18-36-56-519Z_01a0591c-3747-7075-9bb7-cc89a0f65dcd.jsonl",
|
|
77
|
+
"branchLengthAtSessionStart": 3
|
|
78
|
+
},
|
|
79
|
+
"resumed": {
|
|
80
|
+
"requestedSessionId": "01a0591b-51a6-7cf0-8a6d-e9b31594cca4",
|
|
81
|
+
"observedSessionId": "01a0591b-51a6-7cf0-8a6d-e9b31594cca4",
|
|
82
|
+
"file": "/tmp/pi-live-contract/sessions/2026-08-31T18-35-57-734Z_01a0591b-51a6-7cf0-8a6d-e9b31594cca4.jsonl",
|
|
83
|
+
"branchLengthAtSessionStart": 6,
|
|
84
|
+
"priorEntryIdsPreserved": true,
|
|
85
|
+
"modelRecalledPriorTurn": "I output STATUS: success."
|
|
86
|
+
},
|
|
87
|
+
"nameSource": "--name; pi.getSessionName() returns it at session_start, so the extension's rename is a no-op",
|
|
88
|
+
"entryShape": "SessionEntry { type, id, parentId, timestamp, message }; branch also carries non-message entries session_info, model_change, thinking_level_change; assistant message has role/content[{type:text}]/stopReason=stop"
|
|
89
|
+
},
|
|
90
|
+
"extension": {
|
|
91
|
+
"events": [
|
|
92
|
+
"session_start",
|
|
93
|
+
"agent_settled"
|
|
94
|
+
],
|
|
95
|
+
"sessionStartEventShape": [
|
|
96
|
+
"type",
|
|
97
|
+
"reason"
|
|
98
|
+
],
|
|
99
|
+
"agentSettledEventShape": [
|
|
100
|
+
"type"
|
|
101
|
+
],
|
|
102
|
+
"sessionStartContext": {
|
|
103
|
+
"mode": "tui",
|
|
104
|
+
"hasUI": true,
|
|
105
|
+
"cwd": "/tmp/pi-live-contract/project",
|
|
106
|
+
"uiSelectType": "function"
|
|
107
|
+
},
|
|
108
|
+
"actionsExercisedLive": [
|
|
109
|
+
"pi.getSessionName",
|
|
110
|
+
"pi.sendUserMessage",
|
|
111
|
+
"ctx.sessionManager.getSessionId",
|
|
112
|
+
"ctx.sessionManager.getSessionFile",
|
|
113
|
+
"ctx.sessionManager.getBranch",
|
|
114
|
+
"ctx.ui.select"
|
|
115
|
+
],
|
|
116
|
+
"ui": "ctx.ui.select(title, options) -> Promise<string | undefined>; live selector rendered 'Approve relay-flow report for PAY-101:review' with Approve/Reject",
|
|
117
|
+
"hasUIIsTheRealGuard": "ctx.ui.select remains typeof 'function' in print mode while ctx.hasUI is false"
|
|
118
|
+
},
|
|
119
|
+
"errors": {
|
|
120
|
+
"bareDoubleDash": "Error: Unknown option: --",
|
|
121
|
+
"agentFlag": "Error: Unknown option: --agent",
|
|
122
|
+
"interactiveFlag": "Error: Unknown option: --interactive",
|
|
123
|
+
"reportFlag": "Error: Unknown option: --report",
|
|
124
|
+
"agentList": "No named-agent listing command; pi --list-models lists models only"
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Live non-TTY run of installed Pi 0.84.1 (stdin and stdout redirected, real provider):
|
|
2
|
+
|
|
3
|
+
$ pi --name "PAY-101:implement" --session-dir <dir> --model github-copilot/claude-haiku-4.5 \
|
|
4
|
+
--thinking off -nt -e <capture extension> "Reply with exactly: PRINT MODE OK" < /dev/null > out.txt
|
|
5
|
+
exit=0
|
|
6
|
+
stdout: PRINT MODE OK
|
|
7
|
+
|
|
8
|
+
Extension context observed in the same run: mode=print, hasUI=false, stdinIsTTY=false,
|
|
9
|
+
stdoutIsTTY=false, and the process exited after the single response. This is why the
|
|
10
|
+
relay-flow launch path requires the runner's PTY: without both TTY streams Pi selects
|
|
11
|
+
print mode and cannot host an interactive HITL session.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Sanitized excerpts from the live Pi 0.84.1 TUI run (PTY supplied by script(1)); ANSI
|
|
2
|
+
sequences stripped, repeated spinner frames removed.
|
|
3
|
+
|
|
4
|
+
π - PAY-101:review - project <- terminal title from the session name
|
|
5
|
+
/tmp/pi-live-contract/project • PAY-101:review <- footer: cwd • session name (--name)
|
|
6
|
+
↑641 ↓107 $0.001 (sub) 0.4%/200k (auto) claude-haiku-4.5 • thinking off
|
|
7
|
+
|
|
8
|
+
STATUS: success
|
|
9
|
+
...
|
|
10
|
+
VERIFICATION: Ran the live Pi smoke test
|
|
11
|
+
|
|
12
|
+
Approve relay-flow report for PAY-101:review <- ctx.ui.select title from plugin/pi.ts
|
|
13
|
+
Approve
|
|
14
|
+
Reject
|
|
15
|
+
|
|
16
|
+
The process stayed alive after the response settled and after the selector was dismissed
|
|
17
|
+
with Escape; it was terminated by the smoke test, not by Pi.
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
package pi
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"os"
|
|
6
|
+
"path/filepath"
|
|
7
|
+
"strings"
|
|
8
|
+
"testing"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
func TestPiValidateAgentAcceptsDefaultWithPiOnPATH(t *testing.T) {
|
|
12
|
+
fakeDir := strictPiAvailabilityCLI(t)
|
|
13
|
+
t.Setenv("PATH", fakeDir)
|
|
14
|
+
|
|
15
|
+
h := newPiHarness(t)
|
|
16
|
+
if err := h.ValidateAgent(context.Background(), "/srv/payments", "default"); err != nil {
|
|
17
|
+
t.Fatalf("default agent rejected when pi is available: %v", err)
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
func TestPiValidateAgentRejectsUnavailablePi(t *testing.T) {
|
|
22
|
+
directory := t.TempDir()
|
|
23
|
+
// Keep PATH controlled and empty of both the fake and any host-installed Pi.
|
|
24
|
+
t.Setenv("PATH", directory)
|
|
25
|
+
|
|
26
|
+
h := newPiHarness(t)
|
|
27
|
+
err := h.ValidateAgent(context.Background(), "/srv/payments", "default")
|
|
28
|
+
if err == nil {
|
|
29
|
+
t.Fatal("default agent accepted when pi is unavailable")
|
|
30
|
+
}
|
|
31
|
+
if !strings.Contains(err.Error(), "pi") {
|
|
32
|
+
t.Fatalf("error = %q, want Pi availability context", err)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
func TestPiValidateAgentAcceptsExistingRoleAndPiOnPath(t *testing.T) {
|
|
37
|
+
fakeDir := strictPiAvailabilityCLI(t)
|
|
38
|
+
t.Setenv("PATH", fakeDir)
|
|
39
|
+
repoPath := t.TempDir()
|
|
40
|
+
rolePath := writePiRole(t, repoPath, "coder", "You are the coder.\n")
|
|
41
|
+
|
|
42
|
+
h := newPiHarness(t)
|
|
43
|
+
if err := h.ValidateAgent(context.Background(), repoPath, "coder"); err != nil {
|
|
44
|
+
t.Fatalf("existing role %q rejected: %v", rolePath, err)
|
|
45
|
+
}
|
|
46
|
+
if calls := readAvailabilityCalls(t, fakeDir); len(calls) != 0 {
|
|
47
|
+
t.Fatalf("role validation unexpectedly executed Pi: %v", calls)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
func TestPiValidateAgentRejectsMissingRoleBeforePiLookup(t *testing.T) {
|
|
52
|
+
fakeDir := strictPiAvailabilityCLI(t)
|
|
53
|
+
t.Setenv("PATH", fakeDir)
|
|
54
|
+
|
|
55
|
+
h := newPiHarness(t)
|
|
56
|
+
err := h.ValidateAgent(context.Background(), t.TempDir(), "reviewer")
|
|
57
|
+
if err == nil || !strings.Contains(err.Error(), ".pi/roles/reviewer.md") {
|
|
58
|
+
t.Fatalf("missing role error = %v, want role path", err)
|
|
59
|
+
}
|
|
60
|
+
if calls := readAvailabilityCalls(t, fakeDir); len(calls) != 0 {
|
|
61
|
+
t.Fatalf("missing role invoked Pi lookup: %v", calls)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
func TestPiValidateAgentRejectsUnsafeRolePath(t *testing.T) {
|
|
66
|
+
fakeDir := strictPiAvailabilityCLI(t)
|
|
67
|
+
t.Setenv("PATH", fakeDir)
|
|
68
|
+
|
|
69
|
+
h := newPiHarness(t)
|
|
70
|
+
for _, role := range []string{"../coder", "subdir/coder", `subdir\\coder`, " ", "."} {
|
|
71
|
+
if err := h.ValidateAgent(context.Background(), t.TempDir(), role); err == nil {
|
|
72
|
+
t.Errorf("unsafe role %q accepted", role)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if calls := readAvailabilityCalls(t, fakeDir); len(calls) != 0 {
|
|
76
|
+
t.Fatalf("unsafe roles invoked Pi lookup: %v", calls)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
func TestPiValidateAgentRejectsEmptyAndNonRegularRoleFiles(t *testing.T) {
|
|
81
|
+
fakeDir := strictPiAvailabilityCLI(t)
|
|
82
|
+
t.Setenv("PATH", fakeDir)
|
|
83
|
+
|
|
84
|
+
emptyRepo := t.TempDir()
|
|
85
|
+
emptyPath := filepath.Join(emptyRepo, ".pi", "roles", "coder.md")
|
|
86
|
+
if err := os.MkdirAll(filepath.Dir(emptyPath), 0o755); err != nil {
|
|
87
|
+
t.Fatal(err)
|
|
88
|
+
}
|
|
89
|
+
if err := os.WriteFile(emptyPath, nil, 0o644); err != nil {
|
|
90
|
+
t.Fatal(err)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
directoryRepo := t.TempDir()
|
|
94
|
+
directoryPath := filepath.Join(directoryRepo, ".pi", "roles", "reviewer.md")
|
|
95
|
+
if err := os.MkdirAll(directoryPath, 0o755); err != nil {
|
|
96
|
+
t.Fatal(err)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
h := newPiHarness(t)
|
|
100
|
+
if err := h.ValidateAgent(context.Background(), emptyRepo, "coder"); err == nil || !strings.Contains(err.Error(), "empty") {
|
|
101
|
+
t.Fatalf("empty role error = %v, want empty-role error", err)
|
|
102
|
+
}
|
|
103
|
+
if err := h.ValidateAgent(context.Background(), directoryRepo, "reviewer"); err == nil || !strings.Contains(err.Error(), "regular file") {
|
|
104
|
+
t.Fatalf("directory role error = %v, want non-regular-role error", err)
|
|
105
|
+
}
|
|
106
|
+
if calls := readAvailabilityCalls(t, fakeDir); len(calls) != 0 {
|
|
107
|
+
t.Fatalf("invalid role files invoked Pi lookup: %v", calls)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
func TestPiValidateAgentRejectsUnsupportedLabelsWithoutAgentDiscovery(t *testing.T) {
|
|
112
|
+
fakeDir := strictPiAvailabilityCLI(t)
|
|
113
|
+
t.Setenv("PATH", fakeDir)
|
|
114
|
+
|
|
115
|
+
h := newPiHarness(t)
|
|
116
|
+
for _, label := range []string{"build", "plan", "gpt-4o", ""} {
|
|
117
|
+
if err := h.ValidateAgent(context.Background(), "/srv/payments", label); err == nil {
|
|
118
|
+
t.Errorf("unsupported agent label %q accepted", label)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if calls := readAvailabilityCalls(t, fakeDir); len(calls) != 0 {
|
|
122
|
+
t.Fatalf("unsupported labels invoked pi instead of rejecting directly: %v", calls)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
func strictPiAvailabilityCLI(t *testing.T) string {
|
|
127
|
+
t.Helper()
|
|
128
|
+
directory := t.TempDir()
|
|
129
|
+
executable := filepath.Join(directory, "pi")
|
|
130
|
+
calls := filepath.Join(directory, "calls")
|
|
131
|
+
script := `#!/bin/sh
|
|
132
|
+
set -eu
|
|
133
|
+
printf '%s\000' "$*" >> "${PI_FAKE_CALLS:?}"
|
|
134
|
+
case "$#:$*" in
|
|
135
|
+
1:--version) printf '0.84.1\n' ;;
|
|
136
|
+
*) exit 2 ;;
|
|
137
|
+
esac
|
|
138
|
+
`
|
|
139
|
+
if err := os.WriteFile(executable, []byte(script), 0o700); err != nil {
|
|
140
|
+
t.Fatal(err)
|
|
141
|
+
}
|
|
142
|
+
t.Setenv("PI_FAKE_CALLS", calls)
|
|
143
|
+
return directory
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
func readAvailabilityCalls(t *testing.T, directory string) []string {
|
|
147
|
+
t.Helper()
|
|
148
|
+
data, err := os.ReadFile(filepath.Join(directory, "calls"))
|
|
149
|
+
if os.IsNotExist(err) {
|
|
150
|
+
return nil
|
|
151
|
+
}
|
|
152
|
+
if err != nil {
|
|
153
|
+
t.Fatal(err)
|
|
154
|
+
}
|
|
155
|
+
return strings.Split(strings.TrimSuffix(string(data), "\x00"), "\x00")
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
func writePiRole(t *testing.T, repoPath, name, content string) string {
|
|
159
|
+
t.Helper()
|
|
160
|
+
path := filepath.Join(repoPath, ".pi", "roles", name+".md")
|
|
161
|
+
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
162
|
+
t.Fatal(err)
|
|
163
|
+
}
|
|
164
|
+
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
|
165
|
+
t.Fatal(err)
|
|
166
|
+
}
|
|
167
|
+
return path
|
|
168
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
package herdr
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"os/exec"
|
|
6
|
+
"strings"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
// originBaseRef resolves the branch new ticket worktrees are created from.
|
|
10
|
+
// The base is always the repository's origin branch; the ladder is
|
|
11
|
+
// origin/HEAD, then origin/main, then origin/master. A repository without an
|
|
12
|
+
// origin remote falls back to its local main or master.
|
|
13
|
+
//
|
|
14
|
+
// The base only applies when the ticket branch does not exist yet: Herdr
|
|
15
|
+
// checks out an existing branch as-is, so prior agent commits are preserved.
|
|
16
|
+
func originBaseRef(repoPath string) (string, error) {
|
|
17
|
+
if ref, ok := gitValue(repoPath, "symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"); ok && ref != "" {
|
|
18
|
+
return ref, nil
|
|
19
|
+
}
|
|
20
|
+
for _, ref := range []string{"origin/main", "origin/master"} {
|
|
21
|
+
if gitHasRef(repoPath, "refs/remotes/"+ref) {
|
|
22
|
+
return ref, nil
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
if !gitHasOrigin(repoPath) {
|
|
26
|
+
for _, ref := range []string{"main", "master"} {
|
|
27
|
+
if gitHasRef(repoPath, "refs/heads/"+ref) {
|
|
28
|
+
return ref, nil
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return "", fmt.Errorf("herdr: repository %q has no origin/HEAD, origin/main, origin/master, or local main/master to branch from; set it with: git -C %s remote set-head origin -a", repoPath, repoPath)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
func gitValue(repoPath string, args ...string) (string, bool) {
|
|
36
|
+
cmd := exec.Command("git", append([]string{"-C", repoPath}, args...)...)
|
|
37
|
+
out, err := cmd.Output()
|
|
38
|
+
if err != nil {
|
|
39
|
+
return "", false
|
|
40
|
+
}
|
|
41
|
+
return strings.TrimSpace(string(out)), true
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
func gitHasRef(repoPath, ref string) bool {
|
|
45
|
+
_, ok := gitValue(repoPath, "rev-parse", "--verify", "--quiet", ref)
|
|
46
|
+
return ok
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
func gitHasOrigin(repoPath string) bool {
|
|
50
|
+
out, ok := gitValue(repoPath, "remote")
|
|
51
|
+
if !ok {
|
|
52
|
+
return false
|
|
53
|
+
}
|
|
54
|
+
for _, remote := range strings.Fields(out) {
|
|
55
|
+
if remote == "origin" {
|
|
56
|
+
return true
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return false
|
|
60
|
+
}
|