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
|
@@ -259,6 +259,106 @@ func TestDeterministicRunID(t *testing.T) {
|
|
|
259
259
|
}
|
|
260
260
|
}
|
|
261
261
|
|
|
262
|
+
func TestRestartByTicketCreatesNumericFreshAttempt(t *testing.T) {
|
|
263
|
+
log := newEventLog()
|
|
264
|
+
sys := &recordingSystem{log: log}
|
|
265
|
+
wf := testWorkflow("basicFlow")
|
|
266
|
+
latestWorkflow := testWorkflow("basicFlow")
|
|
267
|
+
latestNode := latestWorkflow.Nodes["coding"]
|
|
268
|
+
latestNode.Description = "latest workflow definition"
|
|
269
|
+
latestWorkflow.Nodes["coding"] = latestNode
|
|
270
|
+
logical := identity.NewRunID("payments", wf.Name, "PAY-101")
|
|
271
|
+
previous := run.Run{
|
|
272
|
+
ID: logical, LogicalID: logical, AttemptID: 1,
|
|
273
|
+
Repo: "payments", Workflow: wf.Name,
|
|
274
|
+
Ticket: task.TicketRef{ID: "1", Key: "PAY-101"}, State: run.StateCanceled,
|
|
275
|
+
}
|
|
276
|
+
queries := &fakeQueries{
|
|
277
|
+
byTicket: map[string]run.Run{"PAY-101": previous},
|
|
278
|
+
list: []run.Run{previous},
|
|
279
|
+
}
|
|
280
|
+
exec := &fakeExecutor{log: log, created: true}
|
|
281
|
+
repos := repo.NewRegistry()
|
|
282
|
+
repos.Replace(testRepo(sys))
|
|
283
|
+
workflows := &workflow.Registry{}
|
|
284
|
+
workflows.Replace(latestWorkflow)
|
|
285
|
+
m := &run.RunManager{Executor: exec, Runs: queries, Repos: repos, Workflows: workflows}
|
|
286
|
+
|
|
287
|
+
got, err := m.RestartByTicket(context.Background(), "PAY-101")
|
|
288
|
+
if err != nil {
|
|
289
|
+
t.Fatalf("RestartByTicket failed: %v", err)
|
|
290
|
+
}
|
|
291
|
+
wantID := identity.NewAttemptRunID(logical, 2)
|
|
292
|
+
if got.ID != wantID || got.LogicalID != logical || got.AttemptID != 2 {
|
|
293
|
+
t.Fatalf("restart run = %+v, want ID=%q logical=%q attempt=2", got, wantID, logical)
|
|
294
|
+
}
|
|
295
|
+
if len(exec.ensures) != 1 {
|
|
296
|
+
t.Fatalf("EnsureRun calls = %d, want 1", len(exec.ensures))
|
|
297
|
+
}
|
|
298
|
+
start := exec.ensures[0]
|
|
299
|
+
if start.ID != wantID || start.AttemptID != 2 || start.LogicalID != logical {
|
|
300
|
+
t.Fatalf("restart start = %+v, want numeric attempt 2 and fenced ID", start)
|
|
301
|
+
}
|
|
302
|
+
if start.Workflow.Name != wf.Name || start.Workflow.Nodes["coding"].Description != "latest workflow definition" {
|
|
303
|
+
t.Fatalf("restart did not use latest workflow snapshot: %+v", start.Workflow)
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
func TestRestartByTicketIsIdempotentForActiveFreshAttempt(t *testing.T) {
|
|
308
|
+
attempt := run.Run{
|
|
309
|
+
ID: "payments/basicFlow/PAY-101~attempt~2", LogicalID: "payments/basicFlow/PAY-101", AttemptID: 2,
|
|
310
|
+
Repo: "payments", Workflow: "basicFlow", Ticket: task.TicketRef{Key: "PAY-101"}, State: run.StateBlocked,
|
|
311
|
+
}
|
|
312
|
+
queries := &fakeQueries{byTicket: map[string]run.Run{"PAY-101": attempt}}
|
|
313
|
+
exec := &fakeExecutor{log: newEventLog()}
|
|
314
|
+
m := &run.RunManager{Executor: exec, Runs: queries}
|
|
315
|
+
got, err := m.RestartByTicket(context.Background(), "PAY-101")
|
|
316
|
+
if err != nil {
|
|
317
|
+
t.Fatalf("idempotent restart failed: %v", err)
|
|
318
|
+
}
|
|
319
|
+
if got.ID != attempt.ID || len(exec.ensures) != 0 {
|
|
320
|
+
t.Fatalf("idempotent restart = %+v, EnsureRun calls=%d", got, len(exec.ensures))
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
func TestRestartByTicketWaitsForCancelingAttempt(t *testing.T) {
|
|
325
|
+
attempt := run.Run{
|
|
326
|
+
ID: "payments/basicFlow/PAY-101", LogicalID: "payments/basicFlow/PAY-101", AttemptID: 1,
|
|
327
|
+
Repo: "payments", Workflow: "basicFlow", Ticket: task.TicketRef{Key: "PAY-101"}, State: run.StateCanceling,
|
|
328
|
+
}
|
|
329
|
+
queries := &fakeQueries{byTicket: map[string]run.Run{"PAY-101": attempt}}
|
|
330
|
+
m := &run.RunManager{Executor: &fakeExecutor{log: newEventLog()}, Runs: queries}
|
|
331
|
+
_, err := m.RestartByTicket(context.Background(), "PAY-101")
|
|
332
|
+
if !errors.Is(err, run.ErrRestartConflict) {
|
|
333
|
+
t.Fatalf("restart error = %v, want ErrRestartConflict", err)
|
|
334
|
+
}
|
|
335
|
+
if !strings.Contains(err.Error(), "wait for cancellation") {
|
|
336
|
+
t.Fatalf("restart error = %v, want actionable cancellation guidance", err)
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
func TestEnsureRunReusesActiveRestartAttempt(t *testing.T) {
|
|
341
|
+
log := newEventLog()
|
|
342
|
+
sys := &recordingSystem{log: log, hasCancel: true}
|
|
343
|
+
logical := identity.NewRunID("payments", "basicFlow", "PAY-101")
|
|
344
|
+
attempt := run.Run{
|
|
345
|
+
ID: identity.NewAttemptRunID(logical, 2), LogicalID: logical, AttemptID: 2,
|
|
346
|
+
Repo: "payments", Workflow: "basicFlow", Ticket: task.TicketRef{Key: "PAY-101"}, State: run.StateBlocked,
|
|
347
|
+
}
|
|
348
|
+
exec := &fakeExecutor{log: log}
|
|
349
|
+
m := &run.RunManager{Executor: exec, Runs: &fakeQueries{list: []run.Run{attempt}}}
|
|
350
|
+
ticket := task.Ticket{ID: "1", Key: "PAY-101", WorkflowClaims: []string{"wf:basicFlow"}}
|
|
351
|
+
if err := m.EnsureRun(context.Background(), testRepo(sys), testWorkflow("basicFlow"), ticket); err != nil {
|
|
352
|
+
t.Fatal(err)
|
|
353
|
+
}
|
|
354
|
+
if len(exec.ensures) != 1 || exec.ensures[0].ID != attempt.ID {
|
|
355
|
+
t.Fatalf("ensures = %+v, want active restart ID %q", exec.ensures, attempt.ID)
|
|
356
|
+
}
|
|
357
|
+
if sys.hasCommentN != 0 {
|
|
358
|
+
t.Fatal("active restart attempt checked cancellation marker")
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
262
362
|
func TestCancelByTicketResolvesActiveRun(t *testing.T) {
|
|
263
363
|
rid := identity.NewRunID("payments", "basicFlow", "PAY-101")
|
|
264
364
|
queries := &fakeQueries{byTicket: map[string]run.Run{
|
|
@@ -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
|
+
}
|