relay-flow 0.2.9-alpha → 0.2.11-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.
@@ -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
  }
@@ -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
  }
@@ -0,0 +1,259 @@
1
+ package projection_test
2
+
3
+ import (
4
+ "context"
5
+ "testing"
6
+ "time"
7
+
8
+ "github.com/rajpopat27/relay-flow/internal/run"
9
+ )
10
+
11
+ func TestLegacyNullCreationAndDurationRemainUnavailable(t *testing.T) {
12
+ ctx := context.Background()
13
+ p, db := openProjection(t)
14
+ now := time.Now().UTC()
15
+ if _, err := db.Exec(`INSERT INTO relay_runs (id, repo, workflow, ticket_id, ticket_key, state, started_at, updated_at) VALUES (?, 'repo', 'workflow', 'T-LEGACY', 'T-LEGACY', 'waiting', ?, ?)`, "legacy-null", now, now); err != nil {
16
+ t.Fatal(err)
17
+ }
18
+ detail, err := p.GetDetail(ctx, "legacy-null")
19
+ if err != nil {
20
+ t.Fatal(err)
21
+ }
22
+ if detail.CreatedAt != nil {
23
+ t.Fatalf("legacy CreatedAt = %v, want nil", detail.CreatedAt)
24
+ }
25
+ if err := p.UpsertStep(ctx, run.StepEntry{RunID: "legacy-null", Sequence: 1, Node: "coding", Status: run.StepRunning}); err != nil {
26
+ t.Fatal(err)
27
+ }
28
+ steps, err := p.ListSteps(ctx, "legacy-null")
29
+ if err != nil {
30
+ t.Fatal(err)
31
+ }
32
+ if len(steps) != 1 || steps[0].DurationKnown {
33
+ t.Fatalf("legacy unknown duration = %#v", steps)
34
+ }
35
+ }
36
+
37
+ func TestStepProjectionIsIdempotentAndDerivesDetail(t *testing.T) {
38
+ ctx := context.Background()
39
+ p, _ := openProjection(t)
40
+ start := projectionStart("detail-run", "PAY-DETAIL")
41
+ started := time.Now().UTC().Add(-2 * time.Minute)
42
+ if err := p.InsertStart(ctx, start, started); err != nil {
43
+ t.Fatal(err)
44
+ }
45
+ finished := started.Add(time.Minute)
46
+ step := run.StepEntry{
47
+ RunID: start.ID, Sequence: 1, Node: "coding", NodeVisitID: "visit-1", NodeType: "agent",
48
+ Status: run.StepSucceeded, StartedAt: &started, FinishedAt: &finished,
49
+ Message: "report accepted", Route: "end", Runtime: "coder", Resource: "harness",
50
+ }
51
+ if err := p.UpsertStep(ctx, step); err != nil {
52
+ t.Fatal(err)
53
+ }
54
+ if err := p.UpsertStep(ctx, step); err != nil {
55
+ t.Fatal(err)
56
+ }
57
+ steps, err := p.ListSteps(ctx, start.ID)
58
+ if err != nil {
59
+ t.Fatal(err)
60
+ }
61
+ if len(steps) != 1 || steps[0].NodeVisitID != step.NodeVisitID || steps[0].Route != "end" || steps[0].Duration <= 0 {
62
+ t.Fatalf("steps = %#v", steps)
63
+ }
64
+ detail, err := p.GetDetail(ctx, start.ID)
65
+ if err != nil {
66
+ t.Fatal(err)
67
+ }
68
+ if detail.Progress.Completed != 1 || detail.Progress.Total != 1 || detail.ResourcesDuration.Harness <= 0 {
69
+ t.Fatalf("detail = %#v", detail)
70
+ }
71
+ if detail.Conditions.Completed {
72
+ t.Fatal("starting run was reported completed")
73
+ }
74
+ }
75
+
76
+ func TestStepUpsertDoesNotRegressTerminalVisit(t *testing.T) {
77
+ ctx := context.Background()
78
+ p, _ := openProjection(t)
79
+ start := projectionStart("monotonic-run", "PAY-MONOTONIC")
80
+ if err := p.InsertStart(ctx, start, time.Now().UTC()); err != nil {
81
+ t.Fatal(err)
82
+ }
83
+ finished := time.Now().UTC()
84
+ terminal := run.StepEntry{RunID: start.ID, Sequence: 1, Node: "coding", NodeVisitID: "visit-1", Status: run.StepSucceeded, FinishedAt: &finished, Message: "accepted", Route: "end"}
85
+ if err := p.UpsertStep(ctx, terminal); err != nil {
86
+ t.Fatal(err)
87
+ }
88
+ stale := terminal
89
+ stale.Status, stale.Message, stale.Route = run.StepRunning, "stale retry", "coding"
90
+ if err := p.UpsertStep(ctx, stale); err != nil {
91
+ t.Fatal(err)
92
+ }
93
+ steps, err := p.ListSteps(ctx, start.ID)
94
+ if err != nil {
95
+ t.Fatal(err)
96
+ }
97
+ if len(steps) != 1 || steps[0].Status != run.StepSucceeded || steps[0].Message != "accepted" || steps[0].Route != "end" {
98
+ t.Fatalf("stale write regressed terminal step: %#v", steps)
99
+ }
100
+ }
101
+
102
+ func TestBranchLoopVisitsRemainDistinctAndNested(t *testing.T) {
103
+ ctx := context.Background()
104
+ p, _ := openProjection(t)
105
+ start := projectionStart("branch-loop", "PAY-BRANCH")
106
+ if err := p.InsertStart(ctx, start, time.Now().UTC()); err != nil {
107
+ t.Fatal(err)
108
+ }
109
+ steps := []run.StepEntry{
110
+ {RunID: start.ID, Sequence: 1, Node: "coding", NodeVisitID: "visit-coding-1", Status: run.StepSucceeded, Depth: 1},
111
+ {RunID: start.ID, Sequence: 2, Node: "review", NodeVisitID: "visit-review-1", Status: run.StepSucceeded, Depth: 1},
112
+ {RunID: start.ID, Sequence: 3, Node: "coding", NodeVisitID: "visit-coding-2", ParentSequence: 1, Status: run.StepWaiting, Depth: 2},
113
+ }
114
+ for _, step := range steps {
115
+ if err := p.UpsertStep(ctx, step); err != nil {
116
+ t.Fatal(err)
117
+ }
118
+ }
119
+ got, err := p.ListSteps(ctx, start.ID)
120
+ if err != nil {
121
+ t.Fatal(err)
122
+ }
123
+ if len(got) != len(steps) || got[2].NodeVisitID != "visit-coding-2" || got[2].ParentSequence != 1 || got[2].Depth != 2 {
124
+ t.Fatalf("branch/loop steps = %#v", got)
125
+ }
126
+ }
127
+
128
+ func TestStepProjectionFailureDoesNotBlockAuthoritativeRunState(t *testing.T) {
129
+ ctx := context.Background()
130
+ p, db := openProjection(t)
131
+ start := projectionStart("projection-failure", "PAY-PROJECTION")
132
+ if err := p.InsertStart(ctx, start, time.Now().UTC()); err != nil {
133
+ t.Fatal(err)
134
+ }
135
+ if _, err := db.Exec(`DROP TABLE relay_run_steps`); err != nil {
136
+ t.Fatal(err)
137
+ }
138
+ if err := p.UpdateState(ctx, start.ID, run.StateWaiting, "", nil); err != nil {
139
+ t.Fatalf("authoritative state update failed with missing display table: %v", err)
140
+ }
141
+ got, err := p.Get(ctx, start.ID)
142
+ if err != nil {
143
+ t.Fatal(err)
144
+ }
145
+ if got.State != run.StateWaiting {
146
+ t.Fatalf("state = %s, want waiting", got.State)
147
+ }
148
+ }
149
+
150
+ func TestTerminalStepTimingIsStableAcrossLaterRunFinalization(t *testing.T) {
151
+ ctx := context.Background()
152
+ p, _ := openProjection(t)
153
+ start := projectionStart("stable-terminal", "PAY-STABLE")
154
+ t0 := time.Now().UTC().Add(-2 * time.Minute)
155
+ t1 := t0.Add(20 * time.Second)
156
+ if err := p.InsertStart(ctx, start, t0); err != nil {
157
+ t.Fatal(err)
158
+ }
159
+ if err := p.UpsertStep(ctx, run.StepEntry{RunID: start.ID, Sequence: 1, Node: "coding", Status: run.StepSucceeded, StartedAt: &t0, FinishedAt: &t1}); err != nil {
160
+ t.Fatal(err)
161
+ }
162
+ before, err := p.ListSteps(ctx, start.ID)
163
+ if err != nil {
164
+ t.Fatal(err)
165
+ }
166
+ if len(before) != 1 || before[0].FinishedAt == nil || before[0].Duration <= 0 {
167
+ t.Fatalf("initial terminal step = %#v", before)
168
+ }
169
+ finished := t1.Add(time.Minute)
170
+ if err := p.UpdateState(ctx, start.ID, run.StateCanceled, "canceled", &finished); err != nil {
171
+ t.Fatal(err)
172
+ }
173
+ if err := p.UpdateState(ctx, start.ID, run.StateCanceled, "canceled", &finished); err != nil {
174
+ t.Fatal(err)
175
+ }
176
+ after, err := p.ListSteps(ctx, start.ID)
177
+ if err != nil {
178
+ t.Fatal(err)
179
+ }
180
+ if len(after) != 1 || !after[0].FinishedAt.Equal(*before[0].FinishedAt) || after[0].Duration != before[0].Duration {
181
+ t.Fatalf("terminal timing changed after run finalization: before=%#v after=%#v", before, after)
182
+ }
183
+ }
184
+
185
+ func TestCompletedRunFinalizesActiveStepWhenFinalUpsertIsMissing(t *testing.T) {
186
+ ctx := context.Background()
187
+ p, _ := openProjection(t)
188
+ start := projectionStart("complete-detail", "PAY-COMPLETE-DETAIL")
189
+ started := time.Now().UTC().Add(-time.Minute)
190
+ if err := p.InsertStart(ctx, start, started); err != nil {
191
+ t.Fatal(err)
192
+ }
193
+ if err := p.UpsertStep(ctx, run.StepEntry{RunID: start.ID, Sequence: 1, Node: "end", Status: run.StepRunning, StartedAt: &started}); err != nil {
194
+ t.Fatal(err)
195
+ }
196
+ finished := time.Now().UTC()
197
+ if err := p.UpdateState(ctx, start.ID, run.StateCompleted, "", &finished); err != nil {
198
+ t.Fatal(err)
199
+ }
200
+ steps, err := p.ListSteps(ctx, start.ID)
201
+ if err != nil {
202
+ t.Fatal(err)
203
+ }
204
+ if len(steps) != 1 || steps[0].Status != run.StepSucceeded || steps[0].FinishedAt == nil || steps[0].Duration <= 0 {
205
+ t.Fatalf("completed step = %#v", steps)
206
+ }
207
+ }
208
+
209
+ func TestCanceledRunFinalizesCurrentStepTiming(t *testing.T) {
210
+ ctx := context.Background()
211
+ p, _ := openProjection(t)
212
+ start := projectionStart("cancel-detail", "PAY-CANCEL-DETAIL")
213
+ started := time.Now().UTC().Add(-time.Minute)
214
+ if err := p.InsertStart(ctx, start, started); err != nil {
215
+ t.Fatal(err)
216
+ }
217
+ if err := p.UpsertStep(ctx, run.StepEntry{RunID: start.ID, Sequence: 1, Node: "coding", Status: run.StepRunning, StartedAt: &started}); err != nil {
218
+ t.Fatal(err)
219
+ }
220
+ if err := p.UpdateState(ctx, start.ID, run.StateCanceling, "operator canceled", nil); err != nil {
221
+ t.Fatal(err)
222
+ }
223
+ finished := time.Now().UTC()
224
+ if err := p.UpdateState(ctx, start.ID, run.StateCanceled, "operator canceled", &finished); err != nil {
225
+ t.Fatal(err)
226
+ }
227
+ steps, err := p.ListSteps(ctx, start.ID)
228
+ if err != nil {
229
+ t.Fatal(err)
230
+ }
231
+ if len(steps) != 1 || steps[0].Status != run.StepCanceled || steps[0].FinishedAt == nil || steps[0].Duration <= 0 {
232
+ t.Fatalf("canceled step = %#v", steps)
233
+ }
234
+ }
235
+
236
+ func TestSetupConflictMarksCurrentStepBlocked(t *testing.T) {
237
+ ctx := context.Background()
238
+ p, _ := openProjection(t)
239
+ start := projectionStart("setup-conflict", "PAY-SETUP")
240
+ if err := p.InsertStart(ctx, start, time.Now().UTC()); err != nil {
241
+ t.Fatal(err)
242
+ }
243
+ if err := p.UpsertStep(ctx, run.StepEntry{RunID: start.ID, Sequence: 1, Node: "coding", Status: run.StepRunning}); err != nil {
244
+ t.Fatal(err)
245
+ }
246
+ if err := p.UpdateState(ctx, start.ID, run.StateBlocked, "runner setup conflict", nil); err != nil {
247
+ t.Fatal(err)
248
+ }
249
+ steps, err := p.ListSteps(ctx, start.ID)
250
+ if err != nil {
251
+ t.Fatal(err)
252
+ }
253
+ if len(steps) != 1 || steps[0].Status != run.StepBlocked || steps[0].Message != "runner setup conflict" {
254
+ t.Fatalf("blocked setup step = %#v", steps)
255
+ }
256
+ if steps[0].DurationKnown {
257
+ t.Fatal("unknown setup duration was fabricated as known")
258
+ }
259
+ }