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.
- package/README.md +19 -5
- package/cmd/relay-flow/main.go +224 -23
- package/cmd/relay-flow/observability_test.go +204 -0
- package/cmd/relay-flow/render.go +629 -0
- package/cmd/relay-flow/serve.go +32 -1
- package/internal/execution/goworkflows/activities.go +9 -0
- package/internal/execution/goworkflows/engine.go +15 -0
- package/internal/execution/goworkflows/interpreter.go +86 -3
- package/internal/execution/goworkflows/projection.go +16 -0
- package/internal/execution/projection/detail_test.go +259 -0
- package/internal/execution/projection/projection.go +290 -6
- package/internal/execution/projection/projection_test.go +3 -2
- package/internal/execution/temporal/activities.go +9 -0
- package/internal/execution/temporal/engine.go +13 -0
- package/internal/execution/temporal/interpreter.go +131 -3
- package/internal/execution/temporal/operations.go +174 -0
- package/internal/execution/temporal/operations_test.go +36 -0
- package/internal/harness/opencode/opencode_test.go +1 -1
- package/internal/harness/opencode/repo_setup.go +1 -1
- package/internal/run/detail.go +277 -0
- package/internal/run/detail_test.go +71 -0
- package/internal/run/run.go +4 -0
- package/internal/server/client.go +39 -0
- package/internal/server/observability.go +109 -0
- package/internal/server/observability_test.go +60 -0
- package/internal/server/server.go +36 -0
- package/package.json +1 -1
|
@@ -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.
|
|
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")
|
|
@@ -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
|
+
}
|
package/internal/run/run.go
CHANGED
|
@@ -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
|
+
}
|