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.
@@ -4,6 +4,8 @@ import (
4
4
  "context"
5
5
  "errors"
6
6
  "fmt"
7
+ "log/slog"
8
+ "sort"
7
9
  "time"
8
10
 
9
11
  "github.com/rajpopat27/relay-flow/internal/execution/projection"
@@ -375,6 +377,160 @@ func (e *Engine) workflowStart(ctx context.Context, id, temporalRunID string) (r
375
377
  return run.Start{}, fmt.Errorf("Temporal workflow %s has no WorkflowExecutionStarted snapshot", id)
376
378
  }
377
379
 
380
+ func temporalStepRank(status run.StepStatus) int {
381
+ switch status {
382
+ case run.StepSucceeded, run.StepFailed, run.StepCanceled:
383
+ return 4
384
+ case run.StepBlocked:
385
+ return 3
386
+ case run.StepWaiting:
387
+ return 2
388
+ case run.StepRunning:
389
+ return 1
390
+ default:
391
+ return 0
392
+ }
393
+ }
394
+
395
+ func mergeTemporalStep(existing, incoming run.StepEntry) run.StepEntry {
396
+ if existing.RunID == "" {
397
+ return incoming
398
+ }
399
+ if temporalStepRank(existing.Status) >= 4 || temporalStepRank(incoming.Status) < temporalStepRank(existing.Status) {
400
+ return existing
401
+ }
402
+ if incoming.StartedAt == nil {
403
+ incoming.StartedAt = existing.StartedAt
404
+ }
405
+ if incoming.FinishedAt == nil {
406
+ incoming.FinishedAt = existing.FinishedAt
407
+ }
408
+ if incoming.Duration == 0 {
409
+ incoming.Duration = existing.Duration
410
+ }
411
+ incoming.DurationKnown = incoming.DurationKnown || existing.DurationKnown
412
+ if incoming.Message == "" {
413
+ incoming.Message = existing.Message
414
+ }
415
+ if incoming.Route == "" {
416
+ incoming.Route = existing.Route
417
+ }
418
+ if incoming.Runtime == "" {
419
+ incoming.Runtime = existing.Runtime
420
+ }
421
+ if incoming.Resource == "" {
422
+ incoming.Resource = existing.Resource
423
+ }
424
+ if incoming.ParentSequence == 0 {
425
+ incoming.ParentSequence = existing.ParentSequence
426
+ }
427
+ if incoming.Depth == 0 {
428
+ incoming.Depth = existing.Depth
429
+ }
430
+ return incoming
431
+ }
432
+
433
+ func applyTemporalProjectionState(steps map[int64]run.StepEntry, state run.State, message string, finished *time.Time) {
434
+ var selected int64
435
+ found := false
436
+ for sequence, step := range steps {
437
+ if step.Status != run.StepRunning && step.Status != run.StepWaiting && step.Status != run.StepBlocked {
438
+ continue
439
+ }
440
+ if !found || sequence > selected {
441
+ selected, found = sequence, true
442
+ }
443
+ }
444
+ if !found {
445
+ return
446
+ }
447
+ step := steps[selected]
448
+ switch state {
449
+ case run.StateWaiting:
450
+ step.Status = run.StepWaiting
451
+ case run.StateBlocked:
452
+ step.Status = run.StepBlocked
453
+ case run.StateCanceled, run.StateCanceling:
454
+ step.Status = run.StepCanceled
455
+ default:
456
+ return
457
+ }
458
+ if message != "" {
459
+ step.Message = message
460
+ }
461
+ if finished != nil && step.FinishedAt == nil {
462
+ finish := finished.UTC()
463
+ step.FinishedAt = &finish
464
+ if step.StartedAt != nil && !step.DurationKnown {
465
+ duration := finish.Sub(*step.StartedAt)
466
+ if duration < 0 {
467
+ duration = 0
468
+ }
469
+ step.Duration = duration
470
+ step.DurationKnown = true
471
+ }
472
+ }
473
+ steps[selected] = step
474
+ }
475
+
476
+ // stepsFromHistory reconstructs the display projection for a closed Temporal
477
+ // execution when its workflow query handler is unavailable. It reads only
478
+ // durable Temporal history; it does not call task-system, runner, or harness
479
+ // boundaries and cannot affect execution.
480
+ func (e *Engine) stepsFromHistory(ctx context.Context, id run.ID, temporalRunID string) ([]run.StepEntry, error) {
481
+ c, err := e.ready()
482
+ if err != nil {
483
+ return nil, err
484
+ }
485
+ iterator := c.GetWorkflowHistory(ctx, string(id), temporalRunID, false, enumspb.HISTORY_EVENT_FILTER_TYPE_ALL_EVENT)
486
+ bySequence := map[int64]run.StepEntry{}
487
+ for iterator.HasNext() {
488
+ event, err := iterator.Next()
489
+ if err != nil {
490
+ return nil, fmt.Errorf("read Temporal history for %s: %w", id, err)
491
+ }
492
+ attrs := event.GetActivityTaskScheduledEventAttributes()
493
+ if attrs == nil || attrs.GetActivityType() == nil || attrs.GetInput() == nil {
494
+ continue
495
+ }
496
+ name := attrs.GetActivityType().GetName()
497
+ payloads := attrs.GetInput().GetPayloads()
498
+ if len(payloads) == 0 {
499
+ continue
500
+ }
501
+ switch name {
502
+ case activityProjectionUpsertStep:
503
+ var step run.StepEntry
504
+ if err := converter.GetDefaultDataConverter().FromPayload(payloads[0], &step); err != nil {
505
+ continue
506
+ }
507
+ if step.RunID == "" {
508
+ step.RunID = id
509
+ }
510
+ bySequence[step.Sequence] = mergeTemporalStep(bySequence[step.Sequence], step)
511
+ case activityProjectionUpdateState:
512
+ var activityID run.ID
513
+ var state run.State
514
+ var message string
515
+ var finished *time.Time
516
+ if err := converter.GetDefaultDataConverter().FromPayloads(attrs.GetInput(), &activityID, &state, &message, &finished); err != nil || activityID != id {
517
+ continue
518
+ }
519
+ applyTemporalProjectionState(bySequence, state, message, finished)
520
+ }
521
+ }
522
+ sequences := make([]int64, 0, len(bySequence))
523
+ for sequence := range bySequence {
524
+ sequences = append(sequences, sequence)
525
+ }
526
+ sort.Slice(sequences, func(i, j int) bool { return sequences[i] < sequences[j] })
527
+ out := make([]run.StepEntry, 0, len(sequences))
528
+ for _, sequence := range sequences {
529
+ out = append(out, bySequence[sequence])
530
+ }
531
+ return out, nil
532
+ }
533
+
378
534
  func stateForTemporalStatus(status enumspb.WorkflowExecutionStatus, closeTime *timestamppb.Timestamp) (run.State, *time.Time) {
379
535
  var finished *time.Time
380
536
  if closeTime != nil {
@@ -424,6 +580,12 @@ func (e *Engine) restoreProjection(ctx context.Context, info *workflowpb.Workflo
424
580
  return err
425
581
  }
426
582
  }
583
+ for _, step := range state.Steps {
584
+ step.RunID = id
585
+ if err := e.runs.UpsertStep(ctx, step); err != nil {
586
+ slog.Warn("Temporal step projection restore write unavailable", "runID", string(id), "sequence", step.Sequence, "error", err)
587
+ }
588
+ }
427
589
  if state.Run.State == run.StateCompleted || state.Run.State == run.StateCanceled {
428
590
  finished := state.Run.FinishedAt
429
591
  if finished == nil && info.CloseTime != nil {
@@ -451,5 +613,17 @@ func (e *Engine) restoreProjection(ctx context.Context, info *workflowpb.Workflo
451
613
  if info.Status == enumspb.WORKFLOW_EXECUTION_STATUS_RUNNING {
452
614
  return fmt.Errorf("query Temporal run-state for active workflow %s: %w", id, queryErr)
453
615
  }
616
+ // Closed executions cannot always serve workflow queries. Rebuild the
617
+ // display timeline from scheduled projection activities in retained
618
+ // Temporal history instead of dropping all historical visits.
619
+ if steps, historyErr := e.stepsFromHistory(ctx, id, info.Execution.RunId); historyErr != nil {
620
+ slog.Warn("Temporal step projection history unavailable", "runID", string(id), "error", historyErr)
621
+ } else {
622
+ for _, step := range steps {
623
+ if err := e.runs.UpsertStep(ctx, step); err != nil {
624
+ slog.Warn("Temporal step projection rebuild write unavailable", "runID", string(id), "sequence", step.Sequence, "error", err)
625
+ }
626
+ }
627
+ }
454
628
  return e.runs.UpdateState(ctx, id, stateValue, "", finished)
455
629
  }
@@ -15,6 +15,42 @@ import (
15
15
  _ "modernc.org/sqlite"
16
16
  )
17
17
 
18
+ func TestApplyTemporalProjectionStateRebuildsCanceledTimingIdempotently(t *testing.T) {
19
+ started := time.Now().UTC().Add(-time.Minute)
20
+ finished := started.Add(30 * time.Second)
21
+ later := finished.Add(time.Minute)
22
+ steps := map[int64]run.StepEntry{1: {RunID: "run", Sequence: 1, Node: "coding", Status: run.StepRunning, StartedAt: &started}}
23
+ applyTemporalProjectionState(steps, run.StateCanceled, "operator canceled", &finished)
24
+ first := steps[1]
25
+ if first.Status != run.StepCanceled || first.FinishedAt == nil || !first.FinishedAt.Equal(finished) || first.Duration <= 0 {
26
+ t.Fatalf("rebuilt canceled step = %#v", first)
27
+ }
28
+ applyTemporalProjectionState(steps, run.StateCanceled, "operator canceled", &later)
29
+ second := steps[1]
30
+ if !second.FinishedAt.Equal(finished) || second.Duration != first.Duration {
31
+ t.Fatalf("repeated canceled rebuild changed timing: first=%#v second=%#v", first, second)
32
+ }
33
+ terminalFinished := started.Add(10 * time.Second)
34
+ steps[2] = run.StepEntry{RunID: "run", Sequence: 2, Node: "already-done", Status: run.StepSucceeded, StartedAt: &started, FinishedAt: &terminalFinished, Duration: 10 * time.Second, DurationKnown: true}
35
+ applyTemporalProjectionState(steps, run.StateCanceled, "operator canceled", &later)
36
+ if steps[2].Status != run.StepSucceeded || !steps[2].FinishedAt.Equal(terminalFinished) {
37
+ t.Fatalf("already-terminal step changed: %#v", steps[2])
38
+ }
39
+ }
40
+
41
+ func TestMergeTemporalStepPreservesResourceLabel(t *testing.T) {
42
+ existing := run.StepEntry{RunID: "run", Sequence: 1, Node: "coding", Status: run.StepRunning, Resource: "harness"}
43
+ incoming := run.StepEntry{RunID: "run", Sequence: 1, Node: "coding", Status: run.StepSucceeded, Message: "done"}
44
+ merged := mergeTemporalStep(existing, incoming)
45
+ if merged.Resource != "harness" || merged.Status != run.StepSucceeded || merged.Message != "done" {
46
+ t.Fatalf("merged step = %#v", merged)
47
+ }
48
+ withNewResource := mergeTemporalStep(merged, run.StepEntry{RunID: "run", Sequence: 1, Node: "coding", Status: run.StepSucceeded, Resource: "runner"})
49
+ if withNewResource.Resource != "harness" {
50
+ t.Fatalf("terminal resource was overwritten: %#v", withNewResource)
51
+ }
52
+ }
53
+
18
54
  func TestValidateTemporalExecutionIdentity(t *testing.T) {
19
55
  valid := func(workflowID, workflowType, taskQueue string) *workflowpb.WorkflowExecutionInfo {
20
56
  return &workflowpb.WorkflowExecutionInfo{
@@ -14,7 +14,7 @@ import (
14
14
  "github.com/rajpopat27/relay-flow/internal/workflow"
15
15
  )
16
16
 
17
- const configuredPlugin = "relay-flow-plugin@0.2.9-alpha"
17
+ const configuredPlugin = "relay-flow-plugin@0.2.11-alpha"
18
18
 
19
19
  func TestBuildCommandArgv(t *testing.T) {
20
20
  t.Setenv("RELAY_FLOW_HOME", "/var/lib/relay-flow-test")
@@ -10,7 +10,7 @@ import (
10
10
  "github.com/rajpopat27/relay-flow/internal/config"
11
11
  )
12
12
 
13
- const relayFlowPlugin = "relay-flow-plugin@0.2.9-alpha"
13
+ const relayFlowPlugin = "relay-flow-plugin@0.2.11-alpha"
14
14
 
15
15
  type jsoncToken struct {
16
16
  kind byte
@@ -0,0 +1,277 @@
1
+ package run
2
+
3
+ import (
4
+ "time"
5
+
6
+ "github.com/rajpopat27/relay-flow/internal/workflow"
7
+ )
8
+
9
+ // StepStatus is the display-only lifecycle of one recorded node visit. It is
10
+ // deliberately separate from State: State is the execution authority's
11
+ // current run state, while StepStatus describes one historical row in the
12
+ // relay-owned inspection projection.
13
+ type StepStatus string
14
+
15
+ const (
16
+ StepPending StepStatus = "pending"
17
+ StepRunning StepStatus = "running"
18
+ StepWaiting StepStatus = "waiting"
19
+ StepSucceeded StepStatus = "succeeded"
20
+ StepFailed StepStatus = "failed"
21
+ StepBlocked StepStatus = "blocked"
22
+ StepCanceled StepStatus = "canceled"
23
+ )
24
+
25
+ // StepEntry is the small, engine-neutral read model used by run inspection.
26
+ // Sequence is the stable execution order within one attempt. A node visit is
27
+ // identified by NodeVisitID when the node is a work node; lifecycle rows such
28
+ // as start/end may not have one.
29
+ type StepEntry struct {
30
+ RunID ID `json:"runId,omitempty"`
31
+ Sequence int64 `json:"sequence"`
32
+ Node string `json:"node"`
33
+ NodeVisitID NodeVisitID `json:"nodeVisitId,omitempty"`
34
+ NodeType string `json:"nodeType,omitempty"`
35
+ ParentSequence int64 `json:"parentSequence,omitempty"`
36
+ Depth int `json:"depth,omitempty"`
37
+ Status StepStatus `json:"status"`
38
+ StartedAt *time.Time `json:"startedAt,omitempty"`
39
+ FinishedAt *time.Time `json:"finishedAt,omitempty"`
40
+ Duration time.Duration `json:"duration,omitempty"`
41
+ DurationKnown bool `json:"durationKnown,omitempty"`
42
+ Message string `json:"message,omitempty"`
43
+ Route string `json:"route,omitempty"`
44
+ Runtime string `json:"runtime,omitempty"`
45
+ Resource string `json:"resource,omitempty"`
46
+ }
47
+
48
+ // RunConditions are derived from the current run state and retry metadata.
49
+ // They are display data only and do not participate in execution decisions.
50
+ type RunConditions struct {
51
+ NodeRunning bool `json:"nodeRunning"`
52
+ Waiting bool `json:"waiting"`
53
+ RetryScheduled bool `json:"retryScheduled"`
54
+ Completed bool `json:"completed"`
55
+ Failed bool `json:"failed"`
56
+ Canceled bool `json:"canceled"`
57
+ }
58
+
59
+ // RunProgress counts reached visits in the derived timeline. Pending rows are
60
+ // the denominator remainder for an active/waiting run.
61
+ type RunProgress struct {
62
+ Completed int `json:"completed"`
63
+ Total int `json:"total"`
64
+ Pending int `json:"pending"`
65
+ }
66
+
67
+ // ResourceDuration is an optional display aggregation. Known is false when
68
+ // executors did not retain resource attribution; zero values with Known=true
69
+ // represent a real zero-duration resource interval.
70
+ type ResourceDuration struct {
71
+ Known bool `json:"known"`
72
+ Runner time.Duration `json:"runner,omitempty"`
73
+ TaskSystem time.Duration `json:"taskSystem,omitempty"`
74
+ Harness time.Duration `json:"harness,omitempty"`
75
+ Other time.Duration `json:"other,omitempty"`
76
+ }
77
+
78
+ // RunDetail embeds the original Run value so existing JSON clients that
79
+ // decode a run.Run continue to work when the server adds inspection fields.
80
+ // The remaining fields are read-only relay projection data.
81
+ type RunDetail struct {
82
+ Run
83
+ CreatedAt *time.Time `json:"createdAt,omitempty"`
84
+ Conditions RunConditions `json:"conditions"`
85
+ Progress RunProgress `json:"progress"`
86
+ ResourcesDuration ResourceDuration `json:"resourcesDuration"`
87
+ Steps []StepEntry `json:"steps"`
88
+ }
89
+
90
+ // NewRunDetail creates an empty detail response for a base run. Projection
91
+ // implementations fill Steps and derive the remaining fields.
92
+ func NewRunDetail(r Run) RunDetail {
93
+ return RunDetail{Run: r, Steps: []StepEntry{}}
94
+ }
95
+
96
+ // AddPendingNodes adds only pending downstream rows that can be justified by
97
+ // the current run state and selected route. Completed runs intentionally show
98
+ // only actual visits; the static graph is not treated as execution history.
99
+ func (d *RunDetail) AddPendingNodes(wf workflow.Workflow) {
100
+ if d.State == StateCompleted {
101
+ return
102
+ }
103
+ actual := make(map[string]bool, len(d.Steps))
104
+ maxSequence := int64(-1)
105
+ current := d.CurrentNode
106
+ var currentStep *StepEntry
107
+ for i := range d.Steps {
108
+ step := &d.Steps[i]
109
+ if step.Status == StepPending {
110
+ continue
111
+ }
112
+ actual[step.Node] = true
113
+ if step.Sequence > maxSequence {
114
+ maxSequence = step.Sequence
115
+ }
116
+ if currentStep == nil || step.Sequence > currentStep.Sequence {
117
+ currentStep = step
118
+ }
119
+ }
120
+ if current == "" && currentStep != nil {
121
+ current = currentStep.Node
122
+ }
123
+ if current == "" {
124
+ return
125
+ }
126
+
127
+ candidates := make([]string, 0, 2)
128
+ addCandidate := func(target string) {
129
+ if target == "" || actual[target] {
130
+ return
131
+ }
132
+ for _, existing := range candidates {
133
+ if existing == target {
134
+ return
135
+ }
136
+ }
137
+ candidates = append(candidates, target)
138
+ }
139
+ if currentStep != nil && currentStep.Node == current && currentStep.Route != "" {
140
+ addCandidate(currentStep.Route)
141
+ } else if node, ok := wf.Nodes[current]; ok {
142
+ for _, route := range append(append([]workflow.Route{}, node.OnSuccess...), node.OnFailure...) {
143
+ addCandidate(route.Target)
144
+ }
145
+ }
146
+
147
+ added := map[string]bool{}
148
+ var appendPending func(string, bool)
149
+ appendPending = func(name string, followSingle bool) {
150
+ if name == "" || actual[name] || added[name] {
151
+ return
152
+ }
153
+ node, ok := wf.Nodes[name]
154
+ if !ok {
155
+ return
156
+ }
157
+ added[name] = true
158
+ nodeType := string(node.Type)
159
+ if name == workflow.StartNode || name == workflow.EndNode {
160
+ nodeType = "lifecycle"
161
+ }
162
+ maxSequence++
163
+ d.Steps = append(d.Steps, StepEntry{RunID: d.ID, Sequence: maxSequence,
164
+ Node: name, NodeType: nodeType, Status: StepPending, Depth: 1})
165
+ if !followSingle || name == workflow.EndNode {
166
+ return
167
+ }
168
+ routes := append(append([]workflow.Route{}, node.OnSuccess...), node.OnFailure...)
169
+ if len(routes) == 1 {
170
+ appendPending(routes[0].Target, true)
171
+ }
172
+ }
173
+ for _, candidate := range candidates {
174
+ appendPending(candidate, currentStep != nil && currentStep.Node == current && currentStep.Route != "")
175
+ }
176
+ }
177
+
178
+ // DeriveInspectionFields fills conditions, progress, and resource durations
179
+ // from the supplied run and step timeline. This helper intentionally reads no
180
+ // executor or task-system state.
181
+ func (d *RunDetail) DeriveInspectionFields(now time.Time) {
182
+ if now.IsZero() {
183
+ now = time.Now().UTC()
184
+ }
185
+ d.Conditions = RunConditions{
186
+ NodeRunning: d.State == StateRunning,
187
+ Waiting: d.State == StateWaiting,
188
+ RetryScheduled: d.Retry != nil,
189
+ Completed: d.State == StateCompleted,
190
+ Failed: d.State == StateBlocked,
191
+ Canceled: d.State == StateCanceled || d.State == StateCanceling,
192
+ }
193
+ d.Progress = RunProgress{Total: len(d.Steps)}
194
+ d.ResourcesDuration = ResourceDuration{}
195
+ for i, step := range d.Steps {
196
+ switch step.Status {
197
+ case StepPending:
198
+ d.Progress.Pending++
199
+ default:
200
+ // Progress counts reached visits, including the current waiting or
201
+ // blocked visit; pending rows are the denominator remainder.
202
+ d.Progress.Completed++
203
+ }
204
+ duration := step.Duration
205
+ if duration == 0 && step.StartedAt != nil && step.FinishedAt != nil {
206
+ duration = step.FinishedAt.Sub(*step.StartedAt)
207
+ d.Steps[i].DurationKnown = true
208
+ }
209
+ if duration == 0 && step.StartedAt != nil && step.FinishedAt == nil &&
210
+ (step.Status == StepRunning || step.Status == StepWaiting || step.Status == StepBlocked) {
211
+ duration = now.Sub(*step.StartedAt)
212
+ }
213
+ if duration < 0 {
214
+ duration = 0
215
+ }
216
+ if step.Resource != "" {
217
+ d.ResourcesDuration.Known = true
218
+ }
219
+ switch step.Resource {
220
+ case "runner":
221
+ d.ResourcesDuration.Runner += duration
222
+ case "task-system", "taskSystem":
223
+ d.ResourcesDuration.TaskSystem += duration
224
+ case "harness":
225
+ d.ResourcesDuration.Harness += duration
226
+ case "":
227
+ default:
228
+ d.ResourcesDuration.Other += duration
229
+ }
230
+ }
231
+ }
232
+
233
+ // DisplayStatus returns the stable human status vocabulary used by the CLI.
234
+ func (r Run) DisplayStatus() string {
235
+ switch r.State {
236
+ case StateCompleted:
237
+ return "Succeeded"
238
+ case StateRunning:
239
+ return "Running"
240
+ case StateWaiting:
241
+ return "Waiting"
242
+ case StateBlocked:
243
+ return "Failed"
244
+ case StateCanceled, StateCanceling:
245
+ return "Canceled"
246
+ case StateStarting:
247
+ return "Running"
248
+ default:
249
+ return string(r.State)
250
+ }
251
+ }
252
+
253
+ // DisplayStatus returns the stable human status vocabulary for a step.
254
+ func (s StepStatus) DisplayStatus() string {
255
+ switch s {
256
+ case StepSucceeded:
257
+ return "Succeeded"
258
+ case StepRunning:
259
+ return "Running"
260
+ case StepWaiting:
261
+ return "Waiting"
262
+ case StepFailed, StepBlocked:
263
+ return "Failed"
264
+ case StepCanceled:
265
+ return "Canceled"
266
+ case StepPending:
267
+ return "Pending"
268
+ default:
269
+ return string(s)
270
+ }
271
+ }
272
+
273
+ // IsTerminal reports whether a step has finished and should contribute to
274
+ // progress. It is intentionally local to the display model.
275
+ func (s StepStatus) IsTerminal() bool {
276
+ return s == StepSucceeded || s == StepFailed || s == StepCanceled
277
+ }
@@ -0,0 +1,71 @@
1
+ package run
2
+
3
+ import (
4
+ "encoding/json"
5
+ "strings"
6
+ "testing"
7
+ "time"
8
+
9
+ "github.com/rajpopat27/relay-flow/internal/workflow"
10
+ )
11
+
12
+ func TestRunDetailDerivationIsRepeatable(t *testing.T) {
13
+ started := time.Now().UTC().Add(-time.Minute)
14
+ finished := started.Add(30 * time.Second)
15
+ detail := NewRunDetail(Run{State: StateWaiting})
16
+ detail.Steps = []StepEntry{{Status: StepSucceeded, StartedAt: &started, FinishedAt: &finished, Resource: "harness"}}
17
+ detail.DeriveInspectionFields(time.Now().UTC())
18
+ first := detail.ResourcesDuration.Harness
19
+ detail.DeriveInspectionFields(time.Now().UTC())
20
+ if first <= 0 || detail.ResourcesDuration.Harness != first {
21
+ t.Fatalf("resource duration changed on repeat: first=%s second=%s", first, detail.ResourcesDuration.Harness)
22
+ }
23
+ }
24
+
25
+ func TestCompletedRunDoesNotInventUnvisitedBranchRows(t *testing.T) {
26
+ detail := NewRunDetail(Run{State: StateCompleted, CurrentNode: workflow.EndNode})
27
+ detail.Steps = []StepEntry{
28
+ {Sequence: 0, Node: workflow.StartNode, Status: StepSucceeded},
29
+ {Sequence: 1, Node: "coding", Status: StepSucceeded},
30
+ {Sequence: 2, Node: workflow.EndNode, Status: StepSucceeded},
31
+ }
32
+ detail.AddPendingNodes(workflow.Workflow{Nodes: map[string]workflow.Node{
33
+ workflow.StartNode: {}, "coding": {}, "review": {}, workflow.EndNode: {},
34
+ }})
35
+ if len(detail.Steps) != 3 {
36
+ t.Fatalf("completed branch added pending rows: %#v", detail.Steps)
37
+ }
38
+ }
39
+
40
+ func TestRunDetailAddsPendingStaticNodesWithoutChangingActualVisits(t *testing.T) {
41
+ detail := NewRunDetail(Run{State: StateWaiting, CurrentNode: "coding"})
42
+ detail.Steps = []StepEntry{{Sequence: 0, Node: workflow.StartNode, Status: StepSucceeded, Depth: 1}, {Sequence: 1, Node: "coding", Status: StepWaiting, Depth: 1}}
43
+ detail.AddPendingNodes(workflow.Workflow{Nodes: map[string]workflow.Node{
44
+ workflow.StartNode: {}, "coding": {OnSuccess: []workflow.Route{{Target: workflow.EndNode}}}, workflow.EndNode: {}, "review": {},
45
+ }})
46
+ detail.DeriveInspectionFields(time.Now().UTC())
47
+ if len(detail.Steps) != 3 || detail.Steps[2].Status != StepPending || detail.Progress.Pending != 1 || detail.Progress.Total != 3 {
48
+ t.Fatalf("pending detail = %#v", detail)
49
+ }
50
+ if detail.Steps[0].Status != StepSucceeded || detail.Steps[1].Status != StepWaiting {
51
+ t.Fatalf("actual visits changed: %#v", detail.Steps)
52
+ }
53
+ }
54
+
55
+ func TestRunDetailJSONKeepsBaseRunFieldsAndStepIdentity(t *testing.T) {
56
+ detail := NewRunDetail(Run{ID: "repo/workflow/T-1", State: StateWaiting})
57
+ detail.Steps = []StepEntry{{RunID: detail.ID, Sequence: 1, Node: "coding", Status: StepWaiting}}
58
+ raw, err := json.Marshal(detail)
59
+ if err != nil {
60
+ t.Fatal(err)
61
+ }
62
+ text := string(raw)
63
+ for _, want := range []string{`"id":"repo/workflow/T-1"`, `"steps"`, `"node":"coding"`} {
64
+ if !strings.Contains(text, want) {
65
+ t.Fatalf("JSON %q missing %s", text, want)
66
+ }
67
+ }
68
+ if !strings.Contains(text, `"runId":"repo/workflow/T-1"`) {
69
+ t.Fatalf("step identity missing from JSON: %s", text)
70
+ }
71
+ }
@@ -124,6 +124,10 @@ type ReportAck struct {
124
124
  // its current durable state. Server handlers map it to HTTP 409.
125
125
  var ErrRestartConflict = errors.New("restart conflict")
126
126
 
127
+ // ErrNotFound identifies a missing durable run in the engine-neutral query
128
+ // boundary. HTTP adapters may map it to their own 404 error type.
129
+ var ErrNotFound = errors.New("run not found")
130
+
127
131
  // NodeRuntimeRegistration binds the OpenCode session emitted for one run/node.
128
132
  type NodeRuntimeRegistration struct {
129
133
  RunID ID `json:"runId"`
@@ -118,6 +118,18 @@ func (c *Client) ListWorkflows(ctx context.Context) ([]*workflow.Workflow, error
118
118
  return out, nil
119
119
  }
120
120
 
121
+ // ListWorkflowSummaries returns one additive summary row per workflow. The
122
+ // server computes all rows in one query boundary; a server without the
123
+ // optional capability still returns definitions, which are decoded as rows
124
+ // with zero execution counts.
125
+ func (c *Client) ListWorkflowSummaries(ctx context.Context) ([]WorkflowSummary, error) {
126
+ var out []WorkflowSummary
127
+ if err := c.call(ctx, http.MethodGet, "/workflows?summary=1", nil, &out); err != nil {
128
+ return nil, err
129
+ }
130
+ return out, nil
131
+ }
132
+
121
133
  // GetWorkflow returns one workflow by name.
122
134
  func (c *Client) GetWorkflow(ctx context.Context, name string) (*workflow.Workflow, error) {
123
135
  var wf workflow.Workflow
@@ -127,6 +139,15 @@ func (c *Client) GetWorkflow(ctx context.Context, name string) (*workflow.Workfl
127
139
  return &wf, nil
128
140
  }
129
141
 
142
+ // GetWorkflowDetail returns a definition and a small recent-run summary.
143
+ func (c *Client) GetWorkflowDetail(ctx context.Context, name string) (WorkflowDetail, error) {
144
+ var out WorkflowDetail
145
+ if err := c.call(ctx, http.MethodGet, "/workflows/"+url.PathEscape(name)+"?detail=1", nil, &out); err != nil {
146
+ return WorkflowDetail{}, err
147
+ }
148
+ return out, nil
149
+ }
150
+
130
151
  // DiscoverRepos returns runner-visible registration candidates.
131
152
  func (c *Client) DiscoverRepos(ctx context.Context) ([]runner.RepoCandidate, error) {
132
153
  var out []runner.RepoCandidate
@@ -272,6 +293,13 @@ func (c *Client) ListRuns(ctx context.Context, filter run.Filter) ([]run.Run, er
272
293
  if filter.Ticket != "" {
273
294
  q.Set("ticket", filter.Ticket)
274
295
  }
296
+ if filter.Active != nil {
297
+ if *filter.Active {
298
+ q.Set("active", "1")
299
+ } else {
300
+ q.Set("active", "0")
301
+ }
302
+ }
275
303
  path := "/runs"
276
304
  if s := q.Encode(); s != "" {
277
305
  path += "?" + s
@@ -291,3 +319,14 @@ func (c *Client) GetRunByTicket(ctx context.Context, ticket string) (run.Run, er
291
319
  }
292
320
  return out, nil
293
321
  }
322
+
323
+ // GetRunDetailByTicket returns the additive run inspection response. Existing
324
+ // callers should continue using GetRunByTicket when they only need lifecycle
325
+ // fields.
326
+ func (c *Client) GetRunDetailByTicket(ctx context.Context, ticket string) (run.RunDetail, error) {
327
+ var out run.RunDetail
328
+ if err := c.call(ctx, http.MethodGet, "/runs/by-ticket/"+url.PathEscape(ticket), nil, &out); err != nil {
329
+ return run.RunDetail{}, err
330
+ }
331
+ return out, nil
332
+ }