relay-flow 0.2.8-alpha → 0.2.10-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 (55) hide show
  1. package/README.md +76 -35
  2. package/cmd/relay-flow/commands_test.go +117 -8
  3. package/cmd/relay-flow/main.go +391 -83
  4. package/cmd/relay-flow/observability_test.go +204 -0
  5. package/cmd/relay-flow/onboarding.go +344 -0
  6. package/cmd/relay-flow/onboarding_test.go +515 -0
  7. package/cmd/relay-flow/render.go +629 -0
  8. package/cmd/relay-flow/repo_registration.go +219 -0
  9. package/cmd/relay-flow/serve.go +35 -1
  10. package/go.mod +12 -8
  11. package/go.sum +24 -17
  12. package/internal/execution/goworkflows/activities.go +9 -0
  13. package/internal/execution/goworkflows/engine.go +15 -0
  14. package/internal/execution/goworkflows/engine_test.go +3 -3
  15. package/internal/execution/goworkflows/interpreter.go +86 -3
  16. package/internal/execution/goworkflows/projection.go +16 -0
  17. package/internal/execution/projection/detail_test.go +259 -0
  18. package/internal/execution/projection/projection.go +290 -6
  19. package/internal/execution/projection/projection_test.go +3 -2
  20. package/internal/execution/temporal/activities.go +9 -0
  21. package/internal/execution/temporal/engine.go +13 -0
  22. package/internal/execution/temporal/interpreter.go +131 -3
  23. package/internal/execution/temporal/operations.go +174 -0
  24. package/internal/execution/temporal/operations_test.go +36 -0
  25. package/internal/harness/opencode/opencode_test.go +4 -4
  26. package/internal/harness/opencode/repo_setup.go +1 -1
  27. package/internal/harness/pi/prompt_test.go +3 -3
  28. package/internal/repo/service.go +34 -3
  29. package/internal/repo/service_test.go +22 -0
  30. package/internal/run/detail.go +277 -0
  31. package/internal/run/detail_test.go +71 -0
  32. package/internal/run/run.go +4 -0
  33. package/internal/runner/herdr/herdr.go +103 -4
  34. package/internal/runner/herdr/herdr_test.go +72 -2
  35. package/internal/runner/herdr/herdrcli/contract.go +12 -3
  36. package/internal/runner/herdr/herdrcli/herdrcli_test.go +17 -2
  37. package/internal/runner/herdr/herdrcli/operations.go +16 -0
  38. package/internal/runner/herdr/herdrcli/testdata/strict-herdr.sh +8 -0
  39. package/internal/runner/herdr/herdrcli/testdata/workspace-create.json +1 -0
  40. package/internal/runner/orca/orca.go +68 -9
  41. package/internal/runner/orca/orca_test.go +134 -2
  42. package/internal/runner/orca/orcacli/orcacli.go +21 -1
  43. package/internal/runner/orca/orcacli/orcacli_test.go +22 -0
  44. package/internal/runner/orca/orcacli/testdata/strict-orca.sh +5 -0
  45. package/internal/runner/runner.go +9 -0
  46. package/internal/server/api_test.go +19 -0
  47. package/internal/server/client.go +49 -0
  48. package/internal/server/fixture_test.go +7 -0
  49. package/internal/server/observability.go +109 -0
  50. package/internal/server/observability_test.go +60 -0
  51. package/internal/server/server.go +77 -0
  52. package/internal/task/jira/testdata/jira_search_issues.json +4 -4
  53. package/internal/workflow/workflow.go +4 -1
  54. package/internal/workflow/workflow_test.go +4 -4
  55. package/package.json +1 -1
@@ -3,6 +3,7 @@ package main
3
3
  import (
4
4
  "context"
5
5
  "fmt"
6
+ "path/filepath"
6
7
  "strings"
7
8
 
8
9
  "github.com/charmbracelet/huh"
@@ -13,6 +14,224 @@ import (
13
14
  "github.com/rajpopat27/relay-flow/internal/task"
14
15
  )
15
16
 
17
+ const addRepositorySelection = -1
18
+
19
+ func repoSelectionOptions(candidates []runner.RepoCandidate) []huh.Option[int] {
20
+ options := make([]huh.Option[int], 0, len(candidates)+1)
21
+ for i, candidate := range candidates {
22
+ label := strings.TrimSpace(candidate.Name)
23
+ if label == "" {
24
+ label = candidate.Path
25
+ }
26
+ options = append(options, huh.NewOption(label+" ("+candidate.Path+")", i))
27
+ }
28
+ options = append(options, huh.NewOption("[+] Add repository", addRepositorySelection))
29
+ return options
30
+ }
31
+
32
+ func canonicalRepoPath(path string) string {
33
+ path = strings.TrimSpace(path)
34
+ if path == "" {
35
+ return ""
36
+ }
37
+ absolute, err := filepath.Abs(path)
38
+ if err != nil {
39
+ return filepath.Clean(path)
40
+ }
41
+ absolute = filepath.Clean(absolute)
42
+ if resolved, err := filepath.EvalSymlinks(absolute); err == nil {
43
+ return filepath.Clean(resolved)
44
+ }
45
+ return absolute
46
+ }
47
+
48
+ func selectedRepoPaths(candidates []runner.RepoCandidate, selected []int) []string {
49
+ paths := make([]string, 0, len(selected))
50
+ seen := map[string]bool{}
51
+ for _, index := range selected {
52
+ if index < 0 || index >= len(candidates) {
53
+ continue
54
+ }
55
+ path := canonicalRepoPath(candidates[index].Path)
56
+ if path == "" || seen[path] {
57
+ continue
58
+ }
59
+ seen[path] = true
60
+ paths = append(paths, path)
61
+ }
62
+ return paths
63
+ }
64
+
65
+ func selectedRepoIndices(candidates []runner.RepoCandidate, paths []string) []int {
66
+ wanted := map[string]bool{}
67
+ for _, path := range paths {
68
+ if path != "" {
69
+ wanted[path] = true
70
+ }
71
+ }
72
+ selected := make([]int, 0, len(wanted))
73
+ for i, candidate := range candidates {
74
+ if wanted[canonicalRepoPath(candidate.Path)] {
75
+ selected = append(selected, i)
76
+ }
77
+ }
78
+ return selected
79
+ }
80
+
81
+ func candidateAtPath(candidates []runner.RepoCandidate, path string) bool {
82
+ path = canonicalRepoPath(path)
83
+ for _, candidate := range candidates {
84
+ if canonicalRepoPath(candidate.Path) == path {
85
+ return true
86
+ }
87
+ }
88
+ return false
89
+ }
90
+
91
+ func mergeRepoCandidateNames(candidates []runner.RepoCandidate, names map[string]string) []runner.RepoCandidate {
92
+ out := make([]runner.RepoCandidate, len(candidates))
93
+ copy(out, candidates)
94
+ for i := range out {
95
+ if name := names[canonicalRepoPath(out[i].Path)]; name != "" {
96
+ out[i].Name = name
97
+ }
98
+ }
99
+ return out
100
+ }
101
+
102
+ func validateAddedRepo(candidate runner.RepoCandidate, candidates []runner.RepoCandidate, registered []repo.Info) error {
103
+ candidate.Name = strings.TrimSpace(candidate.Name)
104
+ candidate.Path = canonicalRepoPath(candidate.Path)
105
+ if candidate.Path == "" {
106
+ return fmt.Errorf("repository path is required")
107
+ }
108
+ if candidate.Name == "" {
109
+ return fmt.Errorf("repository name is required")
110
+ }
111
+ for _, existing := range candidates {
112
+ if canonicalRepoPath(existing.Path) == candidate.Path {
113
+ return fmt.Errorf("repository path %q is already discovered as %q", candidate.Path, existing.Name)
114
+ }
115
+ if strings.TrimSpace(existing.Name) == candidate.Name {
116
+ return fmt.Errorf("repository name %q is already discovered", candidate.Name)
117
+ }
118
+ }
119
+ for _, existing := range registered {
120
+ if canonicalRepoPath(existing.Path) == candidate.Path {
121
+ return fmt.Errorf("repository path %q is already registered as %q", candidate.Path, existing.Name)
122
+ }
123
+ if strings.TrimSpace(existing.Name) == candidate.Name {
124
+ return fmt.Errorf("repository name %q is already registered", candidate.Name)
125
+ }
126
+ }
127
+ return nil
128
+ }
129
+
130
+ func promptAddedRepo() (runner.RepoCandidate, error) {
131
+ var path, name string
132
+ pathInput := huh.NewInput().Title("Repository path").Value(&path).Validate(func(value string) error {
133
+ if strings.TrimSpace(value) == "" {
134
+ return fmt.Errorf("repository path is required")
135
+ }
136
+ return nil
137
+ })
138
+ nameInput := huh.NewInput().Title("Registered name").Value(&name).Validate(func(value string) error {
139
+ if strings.TrimSpace(value) == "" {
140
+ return fmt.Errorf("repository name is required")
141
+ }
142
+ return nil
143
+ })
144
+ if err := huh.NewForm(huh.NewGroup(pathInput, nameInput)).Run(); err != nil {
145
+ return runner.RepoCandidate{}, err
146
+ }
147
+ return runner.RepoCandidate{Name: strings.TrimSpace(name), Path: canonicalRepoPath(path)}, nil
148
+ }
149
+
150
+ type repoSelectionPrompt func([]runner.RepoCandidate, []int) ([]int, error)
151
+ type repoAddPrompt func() (runner.RepoCandidate, error)
152
+
153
+ func selectReposInteractive(ctx context.Context, c *server.Client, candidates []runner.RepoCandidate, registered []repo.Info) ([]runner.RepoCandidate, []int, error) {
154
+ return selectReposInteractiveWithPrompts(ctx, c, candidates, registered, promptRepoSelection, promptAddedRepo)
155
+ }
156
+
157
+ func promptRepoSelection(candidates []runner.RepoCandidate, selected []int) ([]int, error) {
158
+ pick := huh.NewForm(huh.NewGroup(repoMultiSelect(repoSelectionOptions(candidates), &selected)))
159
+ if err := pick.Run(); err != nil {
160
+ return nil, err
161
+ }
162
+ return selected, nil
163
+ }
164
+
165
+ func selectReposInteractiveWithPrompts(ctx context.Context, c *server.Client, candidates []runner.RepoCandidate, registered []repo.Info, selectPrompt repoSelectionPrompt, addPrompt repoAddPrompt) ([]runner.RepoCandidate, []int, error) {
166
+ selectedPaths := []string{}
167
+ nameOverrides := map[string]string{}
168
+ addedCandidates := []runner.RepoCandidate{}
169
+ for {
170
+ candidates = mergeRepoCandidateNames(candidates, nameOverrides)
171
+ selected, err := selectPrompt(candidates, selectedRepoIndices(candidates, selectedPaths))
172
+ if err != nil {
173
+ return nil, nil, err
174
+ }
175
+ selectedPaths = selectedRepoPaths(candidates, selected)
176
+ add := false
177
+ for _, value := range selected {
178
+ if value == addRepositorySelection {
179
+ add = true
180
+ break
181
+ }
182
+ }
183
+ if !add {
184
+ if len(selectedPaths) == 0 {
185
+ return nil, nil, fmt.Errorf("select at least one repository")
186
+ }
187
+ return candidates, selectedRepoIndices(candidates, selectedPaths), nil
188
+ }
189
+
190
+ added, err := addPrompt()
191
+ if err != nil {
192
+ return nil, nil, err
193
+ }
194
+ knownCandidates := append(append([]runner.RepoCandidate(nil), candidates...), addedCandidates...)
195
+ if err := validateAddedRepo(added, knownCandidates, registered); err != nil {
196
+ return nil, nil, err
197
+ }
198
+ if err := c.EnsureRepo(ctx, added); err != nil {
199
+ return nil, nil, fmt.Errorf("ensure repository %q: %w", added.Name, err)
200
+ }
201
+ path := canonicalRepoPath(added.Path)
202
+ nameOverrides[path] = added.Name
203
+ addedCandidates = append(addedCandidates, added)
204
+ selectedPaths = append(selectedPaths, path)
205
+
206
+ refreshed, err := c.DiscoverRepos(ctx)
207
+ if err != nil {
208
+ return nil, nil, err
209
+ }
210
+ refreshed = mergeRepoCandidateNames(refreshed, nameOverrides)
211
+ // Preserve selected entries across a runner refresh even if the
212
+ // external runner's listing is briefly eventually consistent.
213
+ for _, old := range candidates {
214
+ oldPath := canonicalRepoPath(old.Path)
215
+ if containsPath(selectedPaths, oldPath) && !candidateAtPath(refreshed, oldPath) {
216
+ refreshed = append(refreshed, old)
217
+ }
218
+ }
219
+ if !candidateAtPath(refreshed, path) {
220
+ refreshed = append(refreshed, added)
221
+ }
222
+ candidates = refreshed
223
+ }
224
+ }
225
+
226
+ func containsPath(paths []string, want string) bool {
227
+ for _, path := range paths {
228
+ if path == want {
229
+ return true
230
+ }
231
+ }
232
+ return false
233
+ }
234
+
16
235
  // loadRepoRegistration asks the selected task plugin for the fields needed by
17
236
  // registration. A second pass lets a plugin expose dependent choices after a
18
237
  // value such as a project has been supplied.
@@ -504,6 +504,7 @@ func (r repoExists) Exists(name string) bool {
504
504
  type durableEngine interface {
505
505
  runsvc.Executor
506
506
  runsvc.RunQueries
507
+ GetRunDetail(context.Context, runsvc.ID) (runsvc.RunDetail, error)
507
508
  Start(context.Context) error
508
509
  Shutdown(context.Context) error
509
510
  HasProcessedReport(context.Context, runsvc.ID, string) (bool, error)
@@ -523,11 +524,34 @@ func (d *serveDeps) SubmitWorkflow(ctx context.Context, yaml []byte) (*workflow.
523
524
  return d.wf.Submit(ctx, yaml)
524
525
  }
525
526
  func (d *serveDeps) GetWorkflow(_ context.Context, name string) (*workflow.Workflow, error) {
526
- return d.wf.Get(name)
527
+ wf, err := d.wf.Get(name)
528
+ if err != nil {
529
+ return nil, fmt.Errorf("%w: %v", server.ErrNotFound, err)
530
+ }
531
+ return wf, nil
527
532
  }
528
533
  func (d *serveDeps) ListWorkflows(context.Context) ([]*workflow.Workflow, error) {
529
534
  return d.wf.List(), nil
530
535
  }
536
+ func (d *serveDeps) ListWorkflowSummaries(ctx context.Context) ([]server.WorkflowSummary, error) {
537
+ workflows := d.wf.List()
538
+ runs, err := d.engine.ListRuns(ctx, runsvc.Filter{})
539
+ if err != nil {
540
+ return nil, err
541
+ }
542
+ return server.BuildWorkflowSummaries(workflows, runs), nil
543
+ }
544
+ func (d *serveDeps) GetWorkflowDetail(ctx context.Context, name string) (server.WorkflowDetail, error) {
545
+ wf, err := d.wf.Get(name)
546
+ if err != nil {
547
+ return server.WorkflowDetail{}, fmt.Errorf("%w: %v", server.ErrNotFound, err)
548
+ }
549
+ runs, err := d.engine.ListRuns(ctx, runsvc.Filter{Workflow: name})
550
+ if err != nil {
551
+ return server.WorkflowDetail{}, err
552
+ }
553
+ return server.BuildWorkflowDetail(wf, runs), nil
554
+ }
531
555
  func (d *serveDeps) RemoveWorkflow(ctx context.Context, name string) error {
532
556
  return d.wf.Remove(ctx, name)
533
557
  }
@@ -538,6 +562,13 @@ func (d *serveDeps) ListRuns(ctx context.Context, filter runsvc.Filter) ([]runsv
538
562
  func (d *serveDeps) GetRunByTicket(ctx context.Context, ticket string) (runsvc.Run, error) {
539
563
  return d.engine.FindRunByTicket(ctx, ticket)
540
564
  }
565
+ func (d *serveDeps) GetRunDetail(ctx context.Context, ticket string) (runsvc.RunDetail, error) {
566
+ base, err := d.engine.FindRunByTicket(ctx, ticket)
567
+ if err != nil {
568
+ return runsvc.RunDetail{}, err
569
+ }
570
+ return d.engine.GetRunDetail(ctx, base.ID)
571
+ }
541
572
  func (d *serveDeps) RestartRun(ctx context.Context, ticket string) (runsvc.Run, error) {
542
573
  rn, err := d.runManager.RestartByTicket(ctx, ticket)
543
574
  if err != nil {
@@ -565,6 +596,9 @@ func (d *serveDeps) RegisterNodeSession(ctx context.Context, registration runsvc
565
596
  func (d *serveDeps) DiscoverRepos(ctx context.Context) ([]runner.RepoCandidate, error) {
566
597
  return d.repos.Discover(ctx)
567
598
  }
599
+ func (d *serveDeps) EnsureRepo(ctx context.Context, candidate runner.RepoCandidate) error {
600
+ return d.repos.EnsureRepo(ctx, candidate.Name, candidate.Path)
601
+ }
568
602
  func (d *serveDeps) TaskRegistrationFields(ctx context.Context, values config.RawValues) ([]task.RegistrationField, error) {
569
603
  return d.repos.RegistrationFields(ctx, values)
570
604
  }
package/go.mod CHANGED
@@ -3,6 +3,7 @@ module github.com/rajpopat27/relay-flow
3
3
  go 1.26.0
4
4
 
5
5
  require (
6
+ github.com/charmbracelet/huh/spinner v0.0.0-20260223110133-9dc45e34a40b
6
7
  github.com/cschleiden/go-workflows v1.4.2
7
8
  github.com/google/renameio/v2 v2.0.1
8
9
  github.com/google/uuid v1.6.0
@@ -16,6 +17,9 @@ require (
16
17
 
17
18
  require (
18
19
  github.com/cespare/xxhash/v2 v2.3.0 // indirect
20
+ github.com/clipperhouse/displaywidth v0.9.0 // indirect
21
+ github.com/clipperhouse/stringish v0.1.1 // indirect
22
+ github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
19
23
  go.opentelemetry.io/auto/sdk v1.2.1 // indirect
20
24
  google.golang.org/genproto/googleapis/api v0.0.0-20260825221802-da73d73af1c5 // indirect
21
25
  google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 // indirect
@@ -27,15 +31,15 @@ require (
27
31
  github.com/benbjohnson/clock v1.3.0 // indirect
28
32
  github.com/catppuccin/go v0.3.0 // indirect
29
33
  github.com/cenkalti/backoff/v4 v4.3.0 // indirect
30
- github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
31
- github.com/charmbracelet/bubbletea v1.3.6 // indirect
32
- github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
34
+ github.com/charmbracelet/bubbles v1.0.0 // indirect
35
+ github.com/charmbracelet/bubbletea v1.3.10 // indirect
36
+ github.com/charmbracelet/colorprofile v0.4.1 // indirect
33
37
  github.com/charmbracelet/huh v1.0.0
34
38
  github.com/charmbracelet/lipgloss v1.1.0 // indirect
35
- github.com/charmbracelet/x/ansi v0.9.3 // indirect
36
- github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
39
+ github.com/charmbracelet/x/ansi v0.11.6 // indirect
40
+ github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
37
41
  github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
38
- github.com/charmbracelet/x/term v0.2.1 // indirect
42
+ github.com/charmbracelet/x/term v0.2.2 // indirect
39
43
  github.com/davecgh/go-spew v1.1.1 // indirect
40
44
  github.com/dustin/go-humanize v1.0.1 // indirect
41
45
  github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
@@ -52,10 +56,10 @@ require (
52
56
  github.com/hashicorp/go-multierror v1.1.1 // indirect
53
57
  github.com/jellydator/ttlcache/v3 v3.0.0 // indirect
54
58
  github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
55
- github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
59
+ github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
56
60
  github.com/mattn/go-isatty v0.0.20
57
61
  github.com/mattn/go-localereader v0.0.1 // indirect
58
- github.com/mattn/go-runewidth v0.0.16 // indirect
62
+ github.com/mattn/go-runewidth v0.0.19 // indirect
59
63
  github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
60
64
  github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
61
65
  github.com/muesli/cancelreader v0.2.2 // indirect
package/go.sum CHANGED
@@ -14,20 +14,22 @@ github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK3
14
14
  github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
15
15
  github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
16
16
  github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
17
- github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=
18
- github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw=
19
- github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU=
20
- github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc=
21
- github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
22
- github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
17
+ github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
18
+ github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
19
+ github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
20
+ github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
21
+ github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
22
+ github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
23
23
  github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw=
24
24
  github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
25
+ github.com/charmbracelet/huh/spinner v0.0.0-20260223110133-9dc45e34a40b h1:deQbW7eR/gYwkXonGX6a1now6H6f8v4kfv0OIKECu0I=
26
+ github.com/charmbracelet/huh/spinner v0.0.0-20260223110133-9dc45e34a40b/go.mod h1:Y68nuKJuC/Q2lmiq18EkHWkVWi2VGLrwaOfOyPKLkkE=
25
27
  github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
26
28
  github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
27
- github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
28
- github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
29
- github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
30
- github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
29
+ github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
30
+ github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
31
+ github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
32
+ github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
31
33
  github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
32
34
  github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
33
35
  github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
@@ -36,12 +38,18 @@ github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payR
36
38
  github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
37
39
  github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
38
40
  github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
39
- github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
40
- github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
41
+ github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
42
+ github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
41
43
  github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
42
44
  github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
43
45
  github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
44
46
  github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
47
+ github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
48
+ github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
49
+ github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
50
+ github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
51
+ github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
52
+ github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
45
53
  github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
46
54
  github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
47
55
  github.com/cschleiden/go-workflows v1.4.2 h1:s7wgx3iKvFmwJVzB7wSuUFqbplcpCUGrn8u1J4/T+2c=
@@ -98,14 +106,14 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
98
106
  github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
99
107
  github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
100
108
  github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
101
- github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
102
- github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
109
+ github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
110
+ github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
103
111
  github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
104
112
  github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
105
113
  github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
106
114
  github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
107
- github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
108
- github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
115
+ github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
116
+ github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
109
117
  github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
110
118
  github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
111
119
  github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
@@ -124,7 +132,6 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
124
132
  github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
125
133
  github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
126
134
  github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
127
- github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
128
135
  github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
129
136
  github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
130
137
  github.com/robfig/cron v1.2.0 h1:ZjScXvvxeQ63Dbyxy76Fj3AT3Ut0aKsyd2/tl3DTMuQ=
@@ -402,6 +402,15 @@ func (a *Activities) CompleteMailbox(ctx context.Context, w run.Work, mailbox ta
402
402
 
403
403
  // Projection activities: idempotent read-model updates.
404
404
 
405
+ func (a *Activities) ProjectionUpsertStep(ctx context.Context, step run.StepEntry) error {
406
+ if err := a.Runs.upsertStep(ctx, step); err != nil {
407
+ // Step detail is a cache. Never let a display-projection failure block
408
+ // route selection, report acceptance, or durable graph progression.
409
+ slog.Warn("step projection unavailable", "runID", string(step.RunID), "node", step.Node, "sequence", step.Sequence, "error", err)
410
+ }
411
+ return nil
412
+ }
413
+
405
414
  func (a *Activities) ProjectionUpdateNode(ctx context.Context, id run.ID, state run.State, node string, visit run.NodeVisitID) error {
406
415
  return a.Runs.updateNode(ctx, id, state, node, visit)
407
416
  }
@@ -211,6 +211,7 @@ func (e *Engine) registerActivities() error {
211
211
  a.CompleteMailbox,
212
212
  a.ProjectionUpdateNodeRuntimeVisit,
213
213
  a.ProjectionRecordProcessedReport,
214
+ a.ProjectionUpsertStep,
214
215
  a.ProjectionUpdateNode,
215
216
  a.ProjectionUpdateState,
216
217
  a.ProjectionUpdateRetry,
@@ -502,6 +503,20 @@ func (e *Engine) FindRunByTicket(ctx context.Context, ticket string) (run.Run, e
502
503
  return e.runs.findByTicket(ctx, ticket)
503
504
  }
504
505
 
506
+ func (e *Engine) GetRunDetail(ctx context.Context, id run.ID) (run.RunDetail, error) {
507
+ detail, err := e.runs.getDetail(ctx, id)
508
+ if err != nil {
509
+ return run.RunDetail{}, err
510
+ }
511
+ // Static definitions are used only to add explicit pending display rows;
512
+ // the projection remains a cache and never influences execution.
513
+ if wf, wfErr := e.workflowOf(ctx, id); wfErr == nil {
514
+ detail.AddPendingNodes(*wf)
515
+ detail.DeriveInspectionFields(time.Now().UTC())
516
+ }
517
+ return detail, nil
518
+ }
519
+
505
520
  func (e *Engine) ListRuns(ctx context.Context, filter run.Filter) ([]run.Run, error) {
506
521
  return e.runs.list(ctx, filter)
507
522
  }
@@ -187,7 +187,7 @@ func TestRunBeginsAtStartAndFollowsEntryEdge(t *testing.T) {
187
187
 
188
188
  wf := linearWorkflow(false)
189
189
  node := wf.Nodes["coding"]
190
- node.NudgePrompt = "Continue {{ticket}} at {{node}}. Valid next steps: {{nextSteps}}."
190
+ node.NudgePrompt = "Continue {{ticket}} at {{node}} in {{mailbox}}. Valid next steps: {{nextSteps}}."
191
191
  wf.Nodes["coding"] = node
192
192
  rid, err := startRun(engine, wf)
193
193
  if err != nil {
@@ -221,8 +221,8 @@ func TestRunBeginsAtStartAndFollowsEntryEdge(t *testing.T) {
221
221
  if call.NudgeTemplate != node.NudgePrompt {
222
222
  t.Fatalf("nudge passed to harness = %q, want raw template %q", call.NudgeTemplate, node.NudgePrompt)
223
223
  }
224
- if call.Data.Ticket != "PAY-101" || call.Data.Workflow != wf.Name || call.Data.Repo != "payments" || call.Data.Node != "coding" || call.Data.NextSteps == "" {
225
- t.Fatalf("nudge prompt data = %+v, want current workflow values", call.Data)
224
+ if call.Data.Ticket != "PAY-101" || call.Data.Workflow != wf.Name || call.Data.Repo != "payments" || call.Data.Node != "coding" || call.Data.Mailbox != "PAY-101-coding" || call.Data.NextSteps == "" {
225
+ t.Fatalf("nudge prompt data = %+v, want current workflow and mailbox values", call.Data)
226
226
  }
227
227
 
228
228
  // Pre-edge gate: before following the start edge the run ensures the
@@ -142,6 +142,23 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
142
142
  return err
143
143
  }
144
144
 
145
+ // The step timeline is a derived display projection. Its sequence is
146
+ // deterministic for replay and is never consulted for routing.
147
+ stepSequence := int64(0)
148
+ target, err := wf.StartTarget()
149
+ if err != nil {
150
+ return err
151
+ }
152
+ startStarted := goworkflow.Now(ctx).UTC()
153
+ if _, err := retryLoop(ctx, start.ID, a, work, "start",
154
+ func(ctx2 goworkflow.Context) goworkflow.Future[struct{}] {
155
+ return goworkflow.ExecuteActivity[struct{}](ctx2, noNativeRetries, a.ProjectionUpsertStep,
156
+ run.StepEntry{RunID: start.ID, Sequence: stepSequence, Node: "start", NodeType: "lifecycle",
157
+ Status: run.StepRunning, StartedAt: &startStarted, Depth: 1})
158
+ }); err != nil {
159
+ return err
160
+ }
161
+
145
162
  // Process the reserved start taskConfig (parent target).
146
163
  startNode := wf.Nodes["start"]
147
164
  if _, err := retryLoop(ctx, start.ID, a, work, "start",
@@ -160,13 +177,21 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
160
177
  return err
161
178
  }
162
179
 
163
- target, err := wf.StartTarget()
164
- if err != nil {
180
+ startFinished := goworkflow.Now(ctx).UTC()
181
+ if _, err := retryLoop(ctx, start.ID, a, work, "start",
182
+ func(ctx2 goworkflow.Context) goworkflow.Future[struct{}] {
183
+ return goworkflow.ExecuteActivity[struct{}](ctx2, noNativeRetries, a.ProjectionUpsertStep,
184
+ run.StepEntry{RunID: start.ID, Sequence: stepSequence, Node: "start", NodeType: "lifecycle",
185
+ Status: run.StepSucceeded, StartedAt: &startStarted, FinishedAt: &startFinished,
186
+ Route: target, Depth: 1})
187
+ }); err != nil {
165
188
  return err
166
189
  }
167
190
 
168
191
  current := target
169
192
  seenReportIDs := map[string]bool{}
193
+ lastStepByNode := map[string]int64{}
194
+ lastDepthByNode := map[string]int{}
170
195
  for current != "end" {
171
196
  node := wf.Nodes[current]
172
197
 
@@ -178,6 +203,27 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
178
203
  return err
179
204
  }
180
205
  visitID := run.NodeVisitID(visit)
206
+ stepSequence++
207
+ stepParent := int64(0)
208
+ stepDepth := 1
209
+ if previous, ok := lastStepByNode[current]; ok {
210
+ stepParent = previous
211
+ stepDepth = lastDepthByNode[current] + 1
212
+ }
213
+ stepStarted := goworkflow.Now(ctx).UTC()
214
+ if _, err := retryLoop(ctx, start.ID, a, work, current,
215
+ func(ctx2 goworkflow.Context) goworkflow.Future[struct{}] {
216
+ return goworkflow.ExecuteActivity[struct{}](ctx2, noNativeRetries, a.ProjectionUpsertStep,
217
+ run.StepEntry{RunID: start.ID, Sequence: stepSequence, Node: current,
218
+ NodeVisitID: visitID, NodeType: string(node.Type), Status: run.StepRunning,
219
+ StartedAt: &stepStarted, ParentSequence: stepParent, Depth: stepDepth,
220
+ Runtime: node.Agent})
221
+ }); err != nil {
222
+ return err
223
+ }
224
+ lastStepByNode[current] = stepSequence
225
+ lastDepthByNode[current] = stepDepth
226
+
181
227
  runtime, err := retryLoop(ctx, start.ID, a, work, current,
182
228
  func(ctx2 goworkflow.Context) goworkflow.Future[NodeRuntime] {
183
229
  return goworkflow.ExecuteActivity[NodeRuntime](ctx2, noNativeRetries,
@@ -328,6 +374,23 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
328
374
  }); err != nil {
329
375
  return err
330
376
  }
377
+ stepStatus := run.StepSucceeded
378
+ stepMessage := report.Summary.Completed
379
+ if report.Status == workflow.OutcomeFailure {
380
+ stepStatus = run.StepFailed
381
+ stepMessage = report.Summary.IssuesDiscovered
382
+ }
383
+ stepFinished := goworkflow.Now(ctx).UTC()
384
+ if _, err := retryLoop(ctx, start.ID, a, work, current,
385
+ func(ctx2 goworkflow.Context) goworkflow.Future[struct{}] {
386
+ return goworkflow.ExecuteActivity[struct{}](ctx2, noNativeRetries, a.ProjectionUpsertStep,
387
+ run.StepEntry{RunID: start.ID, Sequence: stepSequence, Node: current,
388
+ NodeVisitID: visitID, NodeType: string(node.Type), Status: stepStatus,
389
+ StartedAt: &stepStarted, FinishedAt: &stepFinished, Message: stepMessage,
390
+ Route: report.NextStep, Runtime: node.Agent, ParentSequence: stepParent, Depth: stepDepth})
391
+ }); err != nil {
392
+ return err
393
+ }
331
394
 
332
395
  // Ordered transition: summary -> feedback (selected next only) ->
333
396
  // complete current -> process next node.
@@ -389,7 +452,19 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
389
452
  }
390
453
 
391
454
  // end: apply end task config, then optional runner cleanup, then mark
392
- // the run completed.
455
+ // the run completed. Record the lifecycle row before setup so a lost
456
+ // terminal upsert can still be finalized by the authoritative completed
457
+ // state update.
458
+ stepSequence++
459
+ endStarted := goworkflow.Now(ctx).UTC()
460
+ endStep := run.StepEntry{RunID: start.ID, Sequence: stepSequence, Node: "end", NodeType: "lifecycle",
461
+ Status: run.StepRunning, StartedAt: &endStarted, Depth: 1}
462
+ if _, err := retryLoop(ctx, start.ID, a, work, "end",
463
+ func(ctx2 goworkflow.Context) goworkflow.Future[struct{}] {
464
+ return goworkflow.ExecuteActivity[struct{}](ctx2, noNativeRetries, a.ProjectionUpsertStep, endStep)
465
+ }); err != nil {
466
+ return err
467
+ }
393
468
  endNode := wf.Nodes["end"]
394
469
  if _, err := retryLoop(ctx, start.ID, a, work, "end",
395
470
  func(ctx2 goworkflow.Context) goworkflow.Future[struct{}] {
@@ -424,6 +499,14 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
424
499
  return err
425
500
  }
426
501
  }
502
+ endFinished := goworkflow.Now(ctx).UTC()
503
+ endStep.Status, endStep.FinishedAt = run.StepSucceeded, &endFinished
504
+ if _, err := retryLoop(ctx, start.ID, a, work, "end",
505
+ func(ctx2 goworkflow.Context) goworkflow.Future[struct{}] {
506
+ return goworkflow.ExecuteActivity[struct{}](ctx2, noNativeRetries, a.ProjectionUpsertStep, endStep)
507
+ }); err != nil {
508
+ return err
509
+ }
427
510
  if _, err := retryLoop(ctx, start.ID, a, work, "",
428
511
  func(ctx2 goworkflow.Context) goworkflow.Future[struct{}] {
429
512
  now := goworkflow.Now(ctx).UTC()
@@ -54,6 +54,18 @@ func (p *RunProjection) updateNode(ctx context.Context, id run.ID, state run.Sta
54
54
  return p.shared().UpdateNode(ctx, id, state, node, visit)
55
55
  }
56
56
 
57
+ func (p *RunProjection) upsertStep(ctx context.Context, step run.StepEntry) error {
58
+ return p.shared().UpsertStep(ctx, step)
59
+ }
60
+
61
+ func (p *RunProjection) listSteps(ctx context.Context, id run.ID) ([]run.StepEntry, error) {
62
+ return p.shared().ListSteps(ctx, id)
63
+ }
64
+
65
+ func (p *RunProjection) getDetail(ctx context.Context, id run.ID) (run.RunDetail, error) {
66
+ return p.shared().GetDetail(ctx, id)
67
+ }
68
+
57
69
  func (p *RunProjection) getNodeRuntime(ctx context.Context, id run.ID, node string) (NodeRuntime, error) {
58
70
  return p.shared().GetNodeRuntime(ctx, id, node)
59
71
  }
@@ -102,6 +114,10 @@ func (p *RunProjection) get(ctx context.Context, id run.ID) (run.Run, error) {
102
114
  return p.shared().Get(ctx, id)
103
115
  }
104
116
 
117
+ func (p *RunProjection) detail(ctx context.Context, id run.ID) (run.RunDetail, error) {
118
+ return p.getDetail(ctx, id)
119
+ }
120
+
105
121
  func (p *RunProjection) findByTicket(ctx context.Context, ticket string) (run.Run, error) {
106
122
  return p.shared().FindByTicket(ctx, ticket)
107
123
  }