relay-flow 0.2.11-alpha → 0.3.1-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 +6 -6
- package/cmd/relay-flow/main.go +114 -19
- package/cmd/relay-flow/observability_test.go +118 -6
- package/internal/execution/goworkflows/activities.go +11 -6
- package/internal/execution/goworkflows/engine_test.go +52 -0
- package/internal/execution/goworkflows/fakes_test.go +14 -7
- package/internal/execution/goworkflows/interpreter.go +11 -7
- package/internal/execution/goworkflows/node_runtime_test.go +8 -8
- package/internal/execution/temporal/activities.go +11 -6
- package/internal/execution/temporal/end_cleanup_order_test.go +147 -0
- package/internal/execution/temporal/interpreter.go +11 -7
- package/internal/harness/opencode/opencode.go +7 -5
- package/internal/harness/opencode/opencode_test.go +1 -1
- package/internal/harness/opencode/repo_setup.go +1 -1
- package/internal/harness/opencode/validation_test.go +106 -0
- package/internal/harness/pi/pi.go +18 -8
- package/internal/harness/pi/pi_test.go +1 -1
- package/internal/harness/pi/prompt_test.go +2 -2
- package/internal/runner/clean.go +38 -0
- package/internal/runner/clean_test.go +84 -0
- package/internal/runner/herdr/herdr.go +24 -10
- package/internal/runner/herdr/herdr_test.go +66 -0
- package/internal/runner/orca/orca.go +16 -2
- package/internal/runner/orca/orca_test.go +103 -1
- package/package.json +1 -1
|
@@ -282,24 +282,24 @@ func (*adapter) SetEnvironmentStatus(context.Context, runner.Environment, string
|
|
|
282
282
|
// ticketWorkspace resolves the ticket's currently open worktree workspace
|
|
283
283
|
// without creating anything. found is false when the repository, the ticket
|
|
284
284
|
// checkout, or its workspace is absent, which lets cleanup roll forward.
|
|
285
|
-
func (a *adapter) ticketWorkspace(ctx context.Context, spec runner.RunSpec) (string, bool, error) {
|
|
285
|
+
func (a *adapter) ticketWorkspace(ctx context.Context, spec runner.RunSpec) (string, string, bool, error) {
|
|
286
286
|
repoPath := normalizePath(spec.RepoPath)
|
|
287
287
|
if repoPath == "" {
|
|
288
|
-
return "", false, nil
|
|
288
|
+
return "", "", false, nil
|
|
289
289
|
}
|
|
290
290
|
listing, err := a.cli.WorktreeList(ctx, repoPath)
|
|
291
291
|
if err != nil {
|
|
292
292
|
if errors.Is(err, herdrcli.ErrNotGitWorktree) || errors.Is(err, herdrcli.ErrWorktreeNotFound) {
|
|
293
|
-
return "", false, nil
|
|
293
|
+
return "", "", false, nil
|
|
294
294
|
}
|
|
295
|
-
return "", false, err
|
|
295
|
+
return "", "", false, err
|
|
296
296
|
}
|
|
297
297
|
for _, worktree := range listing.Worktrees {
|
|
298
298
|
if worktree.Branch == spec.TicketKey && worktree.OpenWorkspaceID != "" {
|
|
299
|
-
return worktree.OpenWorkspaceID, true, nil
|
|
299
|
+
return worktree.OpenWorkspaceID, normalizePath(worktree.Path), true, nil
|
|
300
300
|
}
|
|
301
301
|
}
|
|
302
|
-
return "", false, nil
|
|
302
|
+
return "", "", false, nil
|
|
303
303
|
}
|
|
304
304
|
|
|
305
305
|
// --- Terminals ---
|
|
@@ -307,7 +307,7 @@ func (a *adapter) ticketWorkspace(ctx context.Context, spec runner.RunSpec) (str
|
|
|
307
307
|
// DiscoverTerminal finds an existing live pane by its stable title during
|
|
308
308
|
// explicit projection recovery. It never creates a workspace or pane.
|
|
309
309
|
func (a *adapter) DiscoverTerminal(ctx context.Context, spec runner.RunSpec, title string) (runner.Terminal, bool, error) {
|
|
310
|
-
workspaceID, found, err := a.ticketWorkspace(ctx, spec)
|
|
310
|
+
workspaceID, _, found, err := a.ticketWorkspace(ctx, spec)
|
|
311
311
|
if err != nil || !found {
|
|
312
312
|
return runner.Terminal{}, false, err
|
|
313
313
|
}
|
|
@@ -549,7 +549,7 @@ func (a *adapter) CloseTerminal(ctx context.Context, terminal runner.Terminal) e
|
|
|
549
549
|
func (a *adapter) CloseTerminals(ctx context.Context, spec runner.RunSpec) error {
|
|
550
550
|
attrs := []any{"ticket", spec.TicketKey, "runID", string(spec.RunID)}
|
|
551
551
|
logCall("close-terminals", attrs...)
|
|
552
|
-
workspaceID, found, err := a.ticketWorkspace(ctx, spec)
|
|
552
|
+
workspaceID, _, found, err := a.ticketWorkspace(ctx, spec)
|
|
553
553
|
if err != nil {
|
|
554
554
|
logOutcome("close-terminals", "error", attrs...)
|
|
555
555
|
return err
|
|
@@ -606,15 +606,29 @@ func (a *adapter) CloseTerminals(ctx context.Context, spec runner.RunSpec) error
|
|
|
606
606
|
|
|
607
607
|
// CleanupRun releases the runner-owned resources for the run: node panes and
|
|
608
608
|
// the ticket workspace. The Git worktree, its branch, and its files are
|
|
609
|
-
// deliberately preserved; a later run reopens the same checkout.
|
|
609
|
+
// deliberately preserved; a later run reopens the same checkout. The ticket
|
|
610
|
+
// checkout must be clean before the workspace is closed.
|
|
610
611
|
func (a *adapter) CleanupRun(ctx context.Context, spec runner.RunSpec) error {
|
|
611
612
|
attrs := []any{"ticket", spec.TicketKey, "runID", string(spec.RunID)}
|
|
612
613
|
logCall("cleanup-run", attrs...)
|
|
614
|
+
workspaceID, checkout, found, err := a.ticketWorkspace(ctx, spec)
|
|
615
|
+
if err != nil {
|
|
616
|
+
logOutcome("cleanup-run", "error", attrs...)
|
|
617
|
+
return err
|
|
618
|
+
}
|
|
619
|
+
if !found {
|
|
620
|
+
logOutcome("cleanup-run", "no-environment", attrs...)
|
|
621
|
+
return nil
|
|
622
|
+
}
|
|
623
|
+
if err := runner.CheckCleanCheckout(ctx, spec.TicketKey, checkout); err != nil {
|
|
624
|
+
logOutcome("cleanup-run", "error", attrs...)
|
|
625
|
+
return err
|
|
626
|
+
}
|
|
613
627
|
if err := a.CloseTerminals(ctx, spec); err != nil {
|
|
614
628
|
logOutcome("cleanup-run", "error", attrs...)
|
|
615
629
|
return err
|
|
616
630
|
}
|
|
617
|
-
workspaceID, found, err
|
|
631
|
+
workspaceID, _, found, err = a.ticketWorkspace(ctx, spec)
|
|
618
632
|
if err != nil {
|
|
619
633
|
logOutcome("cleanup-run", "error", attrs...)
|
|
620
634
|
return err
|
|
@@ -739,6 +739,72 @@ func TestCleanupRunClosesTicketWorkspaceAndKeepsWorktree(t *testing.T) {
|
|
|
739
739
|
}
|
|
740
740
|
}
|
|
741
741
|
|
|
742
|
+
func TestCleanupRunBlocksDirtyCheckoutBeforeClosingHerdrResources(t *testing.T) {
|
|
743
|
+
repo := newGitRepo(t, "main")
|
|
744
|
+
if err := os.WriteFile(filepath.Join(repo, "dirty.txt"), []byte("uncommitted\n"), 0o644); err != nil {
|
|
745
|
+
t.Fatal(err)
|
|
746
|
+
}
|
|
747
|
+
cli := cleanupClientAt(repo)
|
|
748
|
+
spec := runSpec()
|
|
749
|
+
spec.RepoPath = repo
|
|
750
|
+
if err := newAdapter(cli).CleanupRun(context.Background(), spec); err == nil || !strings.Contains(err.Error(), "commit required") {
|
|
751
|
+
t.Fatalf("CleanupRun error = %v, want commit-required dirty-check error", err)
|
|
752
|
+
}
|
|
753
|
+
if len(cli.closedPanes) != 0 || len(cli.closedWorkspaces) != 0 {
|
|
754
|
+
t.Fatalf("dirty cleanup touched Herdr resources: panes=%v workspaces=%v", cli.closedPanes, cli.closedWorkspaces)
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
func TestCleanupRunAllowsCleanCheckoutAndLaterRetry(t *testing.T) {
|
|
759
|
+
repo := newGitRepo(t, "main")
|
|
760
|
+
dirty := filepath.Join(repo, "dirty.txt")
|
|
761
|
+
if err := os.WriteFile(dirty, []byte("uncommitted\n"), 0o644); err != nil {
|
|
762
|
+
t.Fatal(err)
|
|
763
|
+
}
|
|
764
|
+
cli := cleanupClientAt(repo)
|
|
765
|
+
spec := runSpec()
|
|
766
|
+
spec.RepoPath = repo
|
|
767
|
+
a := newAdapter(cli)
|
|
768
|
+
if err := a.CleanupRun(context.Background(), spec); err == nil {
|
|
769
|
+
t.Fatal("dirty CleanupRun succeeded")
|
|
770
|
+
}
|
|
771
|
+
if err := os.Remove(dirty); err != nil {
|
|
772
|
+
t.Fatal(err)
|
|
773
|
+
}
|
|
774
|
+
if err := a.CleanupRun(context.Background(), spec); err != nil {
|
|
775
|
+
t.Fatalf("clean retry CleanupRun = %v", err)
|
|
776
|
+
}
|
|
777
|
+
if len(cli.closedPanes) != 1 || len(cli.closedWorkspaces) != 1 {
|
|
778
|
+
t.Fatalf("clean retry resources: panes=%v workspaces=%v", cli.closedPanes, cli.closedWorkspaces)
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
func TestCleanupRunPropagatesGitStatusFailureBeforeClosingHerdrResources(t *testing.T) {
|
|
783
|
+
repo := t.TempDir()
|
|
784
|
+
cli := cleanupClientAt(repo)
|
|
785
|
+
spec := runSpec()
|
|
786
|
+
spec.RepoPath = repo
|
|
787
|
+
if err := newAdapter(cli).CleanupRun(context.Background(), spec); err == nil {
|
|
788
|
+
t.Fatal("CleanupRun treated Git status failure as clean")
|
|
789
|
+
}
|
|
790
|
+
if len(cli.closedPanes) != 0 || len(cli.closedWorkspaces) != 0 {
|
|
791
|
+
t.Fatalf("Git status failure touched Herdr resources: panes=%v workspaces=%v", cli.closedPanes, cli.closedWorkspaces)
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
func cleanupClientAt(repo string) *fakeClient {
|
|
796
|
+
return &fakeClient{
|
|
797
|
+
listing: herdrcli.WorktreeListing{
|
|
798
|
+
Source: herdrcli.WorktreeSource{RepoName: "payments", RepoRoot: repo, SourceCheckoutPath: repo},
|
|
799
|
+
Worktrees: []herdrcli.Worktree{
|
|
800
|
+
{Path: repo, Branch: "PAY-101", IsLinked: true, OpenWorkspaceID: "w2"},
|
|
801
|
+
},
|
|
802
|
+
},
|
|
803
|
+
tabs: []herdrcli.Tab{{ID: "w2:t2", WorkspaceID: "w2", Label: "PAY-101:coding"}},
|
|
804
|
+
panes: []herdrcli.Pane{{ID: "w2:p2", WorkspaceID: "w2", TabID: "w2:t2", Label: "PAY-101:coding"}},
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
742
808
|
func TestCleanupRollsForwardWhenTicketWorktreeIsGone(t *testing.T) {
|
|
743
809
|
cases := map[string]*fakeClient{
|
|
744
810
|
"repository is not a git work tree": {listingErr: herdrcli.ErrNotGitWorktree},
|
|
@@ -457,14 +457,28 @@ func (a *adapter) CloseTerminals(ctx context.Context, spec runner.RunSpec) error
|
|
|
457
457
|
}
|
|
458
458
|
|
|
459
459
|
// CleanupRun removes all runner-owned run resources: terminals, then the
|
|
460
|
-
// ticket worktree itself.
|
|
460
|
+
// ticket worktree itself. The ticket checkout must be clean before either
|
|
461
|
+
// resource is released.
|
|
461
462
|
func (a *adapter) CleanupRun(ctx context.Context, spec runner.RunSpec) error {
|
|
462
463
|
slog.Debug("orca call", "op", "cleanup-run", "ticket", spec.TicketKey, "runID", string(spec.RunID))
|
|
464
|
+
env, ok, err := a.findEnvironment(ctx, spec)
|
|
465
|
+
if err != nil || !ok {
|
|
466
|
+
if err != nil {
|
|
467
|
+
slog.Info("orca outcome", "op", "cleanup-run", "ticket", spec.TicketKey, "result", "error", "error", sanitizeErr(err))
|
|
468
|
+
} else {
|
|
469
|
+
slog.Info("orca outcome", "op", "cleanup-run", "ticket", spec.TicketKey, "result", "no-environment")
|
|
470
|
+
}
|
|
471
|
+
return err
|
|
472
|
+
}
|
|
473
|
+
if err := runner.CheckCleanCheckout(ctx, spec.TicketKey, env.Path); err != nil {
|
|
474
|
+
slog.Info("orca outcome", "op", "cleanup-run", "ticket", spec.TicketKey, "result", "error", "error", sanitizeErr(err))
|
|
475
|
+
return err
|
|
476
|
+
}
|
|
463
477
|
if err := a.CloseTerminals(ctx, spec); err != nil {
|
|
464
478
|
slog.Info("orca outcome", "op", "cleanup-run", "ticket", spec.TicketKey, "result", "error", "error", sanitizeErr(err))
|
|
465
479
|
return err
|
|
466
480
|
}
|
|
467
|
-
env, ok, err
|
|
481
|
+
env, ok, err = a.findEnvironment(ctx, spec)
|
|
468
482
|
if err != nil || !ok {
|
|
469
483
|
if err != nil {
|
|
470
484
|
slog.Info("orca outcome", "op", "cleanup-run", "ticket", spec.TicketKey, "result", "error", "error", sanitizeErr(err))
|
|
@@ -3,6 +3,7 @@ package orca
|
|
|
3
3
|
import (
|
|
4
4
|
"context"
|
|
5
5
|
"errors"
|
|
6
|
+
"os"
|
|
6
7
|
"os/exec"
|
|
7
8
|
"strings"
|
|
8
9
|
"testing"
|
|
@@ -404,7 +405,7 @@ func TestCloseTerminalsContinuesAfterStaleHandle(t *testing.T) {
|
|
|
404
405
|
func TestCleanupRunDeletesWorktreeAfterStaleHandle(t *testing.T) {
|
|
405
406
|
fx := &fakeCLI{
|
|
406
407
|
repos: []orcacli.Repo{{ID: "r1", DisplayName: "app", Path: "/srv/app"}},
|
|
407
|
-
worktrees: []orcacli.Worktree{{ID: "wt-PAY-1", RepoID: "r1", DisplayName: "PAY-1"}},
|
|
408
|
+
worktrees: []orcacli.Worktree{{ID: "wt-PAY-1", RepoID: "r1", DisplayName: "PAY-1", Path: "/wt/PAY-1"}},
|
|
408
409
|
listedTerminals: []orcacli.Terminal{{Handle: "term-stale", Title: "PAY-1:implement", Connected: true}},
|
|
409
410
|
closeErrors: map[string]error{"term-stale": orcacli.ErrTerminalUnavailable},
|
|
410
411
|
}
|
|
@@ -422,6 +423,107 @@ func TestCleanupRunDeletesWorktreeAfterStaleHandle(t *testing.T) {
|
|
|
422
423
|
}
|
|
423
424
|
}
|
|
424
425
|
|
|
426
|
+
func TestCleanupRunBlocksDirtyCheckoutBeforeResourceCleanup(t *testing.T) {
|
|
427
|
+
repo := newOrcaCleanCheckout(t)
|
|
428
|
+
if err := os.WriteFile(repo+"/dirty.txt", []byte("uncommitted\n"), 0o644); err != nil {
|
|
429
|
+
t.Fatal(err)
|
|
430
|
+
}
|
|
431
|
+
fx := &fakeCLI{
|
|
432
|
+
repos: []orcacli.Repo{{ID: "r1", DisplayName: "app", Path: repo}},
|
|
433
|
+
worktrees: []orcacli.Worktree{{ID: "wt-PAY-1", RepoID: "r1", DisplayName: "PAY-1", Path: repo}},
|
|
434
|
+
listedTerminals: []orcacli.Terminal{{Handle: "term-live", Title: "PAY-1:implement", Connected: true}},
|
|
435
|
+
}
|
|
436
|
+
a, err := New(fx, config.RawValues{})
|
|
437
|
+
if err != nil {
|
|
438
|
+
t.Fatal(err)
|
|
439
|
+
}
|
|
440
|
+
err = a.CleanupRun(context.Background(), runner.RunSpec{RepoName: "app", RepoPath: repo, TicketKey: "PAY-1"})
|
|
441
|
+
if err == nil || !strings.Contains(err.Error(), "commit required") {
|
|
442
|
+
t.Fatalf("CleanupRun error = %v, want commit-required dirty-check error", err)
|
|
443
|
+
}
|
|
444
|
+
if len(fx.closedHandles) != 0 || len(fx.deletedWorktrees) != 0 {
|
|
445
|
+
t.Fatalf("dirty cleanup touched runner resources: closed=%v deleted=%v", fx.closedHandles, fx.deletedWorktrees)
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
func TestCleanupRunAllowsCleanCheckoutAndLaterRetry(t *testing.T) {
|
|
450
|
+
repo := newOrcaCleanCheckout(t)
|
|
451
|
+
dirty := repo + "/dirty.txt"
|
|
452
|
+
if err := os.WriteFile(dirty, []byte("uncommitted\n"), 0o644); err != nil {
|
|
453
|
+
t.Fatal(err)
|
|
454
|
+
}
|
|
455
|
+
fx := &fakeCLI{
|
|
456
|
+
repos: []orcacli.Repo{{ID: "r1", DisplayName: "app", Path: repo}},
|
|
457
|
+
worktrees: []orcacli.Worktree{{ID: "wt-PAY-1", RepoID: "r1", DisplayName: "PAY-1", Path: repo}},
|
|
458
|
+
listedTerminals: []orcacli.Terminal{{Handle: "term-live", Title: "PAY-1:implement", Connected: true}},
|
|
459
|
+
}
|
|
460
|
+
a, err := New(fx, config.RawValues{})
|
|
461
|
+
if err != nil {
|
|
462
|
+
t.Fatal(err)
|
|
463
|
+
}
|
|
464
|
+
spec := runner.RunSpec{RepoName: "app", RepoPath: repo, TicketKey: "PAY-1"}
|
|
465
|
+
if err := a.CleanupRun(context.Background(), spec); err == nil {
|
|
466
|
+
t.Fatal("dirty CleanupRun succeeded")
|
|
467
|
+
}
|
|
468
|
+
if err := os.Remove(dirty); err != nil {
|
|
469
|
+
t.Fatal(err)
|
|
470
|
+
}
|
|
471
|
+
if err := a.CleanupRun(context.Background(), spec); err != nil {
|
|
472
|
+
t.Fatalf("clean retry CleanupRun = %v", err)
|
|
473
|
+
}
|
|
474
|
+
if len(fx.closedHandles) != 1 || len(fx.deletedWorktrees) != 1 {
|
|
475
|
+
t.Fatalf("clean retry resources: closed=%v deleted=%v", fx.closedHandles, fx.deletedWorktrees)
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
func TestCleanupRunPropagatesGitStatusFailureBeforeResourceCleanup(t *testing.T) {
|
|
480
|
+
repo := t.TempDir()
|
|
481
|
+
fx := &fakeCLI{
|
|
482
|
+
repos: []orcacli.Repo{{ID: "r1", DisplayName: "app", Path: repo}},
|
|
483
|
+
worktrees: []orcacli.Worktree{{ID: "wt-PAY-1", RepoID: "r1", DisplayName: "PAY-1", Path: repo}},
|
|
484
|
+
}
|
|
485
|
+
a, err := New(fx, config.RawValues{})
|
|
486
|
+
if err != nil {
|
|
487
|
+
t.Fatal(err)
|
|
488
|
+
}
|
|
489
|
+
if err := a.CleanupRun(context.Background(), runner.RunSpec{RepoName: "app", RepoPath: repo, TicketKey: "PAY-1"}); err == nil {
|
|
490
|
+
t.Fatal("CleanupRun treated Git status failure as clean")
|
|
491
|
+
}
|
|
492
|
+
if len(fx.closedHandles) != 0 || len(fx.deletedWorktrees) != 0 {
|
|
493
|
+
t.Fatalf("Git status failure touched runner resources: closed=%v deleted=%v", fx.closedHandles, fx.deletedWorktrees)
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
func newOrcaCleanCheckout(t *testing.T) string {
|
|
498
|
+
t.Helper()
|
|
499
|
+
repo := t.TempDir()
|
|
500
|
+
for _, args := range [][]string{
|
|
501
|
+
{"init", "-q", "-b", "main"},
|
|
502
|
+
{"config", "user.email", "relay-flow@example.invalid"},
|
|
503
|
+
{"config", "user.name", "Relay Flow"},
|
|
504
|
+
} {
|
|
505
|
+
cmd := exec.Command("git", args...)
|
|
506
|
+
cmd.Dir = repo
|
|
507
|
+
if output, err := cmd.CombinedOutput(); err != nil {
|
|
508
|
+
t.Fatalf("git %v: %v: %s", args, err, output)
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
if err := os.WriteFile(repo+"/tracked.txt", []byte("clean\n"), 0o644); err != nil {
|
|
512
|
+
t.Fatal(err)
|
|
513
|
+
}
|
|
514
|
+
cmd := exec.Command("git", "add", "tracked.txt")
|
|
515
|
+
cmd.Dir = repo
|
|
516
|
+
if output, err := cmd.CombinedOutput(); err != nil {
|
|
517
|
+
t.Fatalf("git add: %v: %s", err, output)
|
|
518
|
+
}
|
|
519
|
+
cmd = exec.Command("git", "commit", "-qm", "initial")
|
|
520
|
+
cmd.Dir = repo
|
|
521
|
+
if output, err := cmd.CombinedOutput(); err != nil {
|
|
522
|
+
t.Fatalf("git commit: %v: %s", err, output)
|
|
523
|
+
}
|
|
524
|
+
return repo
|
|
525
|
+
}
|
|
526
|
+
|
|
425
527
|
// An existing ticket branch must win even over a configured baseRef. Passing
|
|
426
528
|
// any other base would make Orca hit its branch-name collision behavior.
|
|
427
529
|
func TestEnsureEnvironment_ExistingTicketBranchAvoidsCollision(t *testing.T) {
|