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.
Files changed (77) hide show
  1. package/README.md +154 -13
  2. package/cmd/relay-flow/commands_test.go +5 -1
  3. package/cmd/relay-flow/main.go +27 -1
  4. package/cmd/relay-flow/pi_wiring_test.go +64 -0
  5. package/cmd/relay-flow/serve.go +14 -0
  6. package/examples/beads-workflow.yaml +3 -3
  7. package/examples/config-reference.yaml +144 -0
  8. package/examples/minimal-beads-task-workflow.yaml +34 -0
  9. package/examples/minimal-jira-task-workflow.yaml +68 -0
  10. package/examples/workflow-reference.yaml +111 -0
  11. package/internal/execution/goworkflows/activities.go +19 -0
  12. package/internal/execution/goworkflows/engine.go +20 -0
  13. package/internal/execution/goworkflows/engine_test.go +1 -1
  14. package/internal/execution/goworkflows/fakes_test.go +34 -6
  15. package/internal/execution/goworkflows/interpreter.go +48 -1
  16. package/internal/execution/goworkflows/projection.go +52 -11
  17. package/internal/execution/goworkflows/recovery_test.go +129 -0
  18. package/internal/harness/opencode/opencode.go +4 -4
  19. package/internal/harness/opencode/opencode_test.go +59 -4
  20. package/internal/harness/opencode/repo_setup.go +42 -2
  21. package/internal/harness/pi/config_test.go +46 -0
  22. package/internal/harness/pi/lifecycle_test.go +69 -0
  23. package/internal/harness/pi/pi.go +261 -0
  24. package/internal/harness/pi/pi_test.go +322 -0
  25. package/internal/harness/pi/prompt_test.go +121 -0
  26. package/internal/harness/pi/testdata/pi-0.84.1/capture.json +126 -0
  27. package/internal/harness/pi/testdata/pi-0.84.1/noninteractive-output.txt +11 -0
  28. package/internal/harness/pi/testdata/pi-0.84.1/tui-output-sanitized.txt +17 -0
  29. package/internal/harness/pi/validation_test.go +168 -0
  30. package/internal/identity/identity.go +28 -1
  31. package/internal/identity/identity_test.go +40 -0
  32. package/internal/run/manager.go +193 -25
  33. package/internal/run/run.go +24 -6
  34. package/internal/run/run_manager_test.go +100 -0
  35. package/internal/runner/herdr/baseref.go +60 -0
  36. package/internal/runner/herdr/herdr.go +578 -0
  37. package/internal/runner/herdr/herdr_test.go +688 -0
  38. package/internal/runner/herdr/herdrcli/contract.go +128 -0
  39. package/internal/runner/herdr/herdrcli/exec.go +68 -0
  40. package/internal/runner/herdr/herdrcli/herdrcli_test.go +274 -0
  41. package/internal/runner/herdr/herdrcli/live_test.go +132 -0
  42. package/internal/runner/herdr/herdrcli/operations.go +281 -0
  43. package/internal/runner/herdr/herdrcli/response.go +116 -0
  44. package/internal/runner/herdr/herdrcli/testdata/empty-panes.json +1 -0
  45. package/internal/runner/herdr/herdrcli/testdata/empty-tabs.json +1 -0
  46. package/internal/runner/herdr/herdrcli/testdata/error-not-git-worktree.json +1 -0
  47. package/internal/runner/herdr/herdrcli/testdata/error-pane-not-found.json +1 -0
  48. package/internal/runner/herdr/herdrcli/testdata/error-workspace-not-found.json +1 -0
  49. package/internal/runner/herdr/herdrcli/testdata/error-worktree-not-found.json +1 -0
  50. package/internal/runner/herdr/herdrcli/testdata/malformed.json +1 -0
  51. package/internal/runner/herdr/herdrcli/testdata/pane-close.json +6 -0
  52. package/internal/runner/herdr/herdrcli/testdata/pane-get.json +25 -0
  53. package/internal/runner/herdr/herdrcli/testdata/pane-list.json +45 -0
  54. package/internal/runner/herdr/herdrcli/testdata/pane-process-info-shell.json +22 -0
  55. package/internal/runner/herdr/herdrcli/testdata/pane-process-info.json +23 -0
  56. package/internal/runner/herdr/herdrcli/testdata/pane-rename.json +23 -0
  57. package/internal/runner/herdr/herdrcli/testdata/snapshot.json +213 -0
  58. package/internal/runner/herdr/herdrcli/testdata/strict-herdr.sh +175 -0
  59. package/internal/runner/herdr/herdrcli/testdata/tab-create.json +31 -0
  60. package/internal/runner/herdr/herdrcli/testdata/tab-list.json +26 -0
  61. package/internal/runner/herdr/herdrcli/testdata/workspace-close.json +6 -0
  62. package/internal/runner/herdr/herdrcli/testdata/worktree-create.json +58 -0
  63. package/internal/runner/herdr/herdrcli/testdata/worktree-list.json +35 -0
  64. package/internal/runner/herdr/herdrcli/testdata/worktree-open.json +59 -0
  65. package/internal/server/api_test.go +54 -0
  66. package/internal/server/client.go +11 -0
  67. package/internal/server/fixture_test.go +18 -0
  68. package/internal/server/server.go +16 -0
  69. package/internal/task/beads/beads.go +16 -16
  70. package/internal/task/beads/config_compatibility_test.go +7 -8
  71. package/internal/task/beads/status_compatibility_test.go +43 -0
  72. package/internal/task/jira/filters_test.go +32 -6
  73. package/internal/task/jira/helpers_test.go +3 -0
  74. package/internal/task/jira/jira.go +43 -17
  75. package/internal/task/jira/transition_defaults_test.go +43 -0
  76. package/internal/task/task.go +8 -0
  77. 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
+ }