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.
@@ -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 := a.ticketWorkspace(ctx, spec)
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 := a.findEnvironment(ctx, spec)
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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-flow",
3
- "version": "0.2.11-alpha",
3
+ "version": "0.3.1-alpha",
4
4
  "description": "Graph-based agent workflow engine — tracker-agnostic, pluggable runners",
5
5
  "bin": {
6
6
  "relay-flow": "./bin/relay-flow.js",