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.
- package/README.md +76 -35
- package/cmd/relay-flow/commands_test.go +117 -8
- package/cmd/relay-flow/main.go +391 -83
- package/cmd/relay-flow/observability_test.go +204 -0
- package/cmd/relay-flow/onboarding.go +344 -0
- package/cmd/relay-flow/onboarding_test.go +515 -0
- package/cmd/relay-flow/render.go +629 -0
- package/cmd/relay-flow/repo_registration.go +219 -0
- package/cmd/relay-flow/serve.go +35 -1
- package/go.mod +12 -8
- package/go.sum +24 -17
- package/internal/execution/goworkflows/activities.go +9 -0
- package/internal/execution/goworkflows/engine.go +15 -0
- package/internal/execution/goworkflows/engine_test.go +3 -3
- 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 +4 -4
- package/internal/harness/opencode/repo_setup.go +1 -1
- package/internal/harness/pi/prompt_test.go +3 -3
- package/internal/repo/service.go +34 -3
- package/internal/repo/service_test.go +22 -0
- package/internal/run/detail.go +277 -0
- package/internal/run/detail_test.go +71 -0
- package/internal/run/run.go +4 -0
- package/internal/runner/herdr/herdr.go +103 -4
- package/internal/runner/herdr/herdr_test.go +72 -2
- package/internal/runner/herdr/herdrcli/contract.go +12 -3
- package/internal/runner/herdr/herdrcli/herdrcli_test.go +17 -2
- package/internal/runner/herdr/herdrcli/operations.go +16 -0
- package/internal/runner/herdr/herdrcli/testdata/strict-herdr.sh +8 -0
- package/internal/runner/herdr/herdrcli/testdata/workspace-create.json +1 -0
- package/internal/runner/orca/orca.go +68 -9
- package/internal/runner/orca/orca_test.go +134 -2
- package/internal/runner/orca/orcacli/orcacli.go +21 -1
- package/internal/runner/orca/orcacli/orcacli_test.go +22 -0
- package/internal/runner/orca/orcacli/testdata/strict-orca.sh +5 -0
- package/internal/runner/runner.go +9 -0
- package/internal/server/api_test.go +19 -0
- package/internal/server/client.go +49 -0
- package/internal/server/fixture_test.go +7 -0
- package/internal/server/observability.go +109 -0
- package/internal/server/observability_test.go +60 -0
- package/internal/server/server.go +77 -0
- package/internal/task/jira/testdata/jira_search_issues.json +4 -4
- package/internal/workflow/workflow.go +4 -1
- package/internal/workflow/workflow_test.go +4 -4
- package/package.json +1 -1
|
@@ -42,6 +42,7 @@ const (
|
|
|
42
42
|
activityComment = "Comment"
|
|
43
43
|
activityCompleteMailbox = "CompleteMailbox"
|
|
44
44
|
activityProjectionUpdateNodeRuntime = "ProjectionUpdateNodeRuntimeVisit"
|
|
45
|
+
activityProjectionUpsertStep = "ProjectionUpsertStep"
|
|
45
46
|
activityProjectionRecordReport = "ProjectionRecordProcessedReport"
|
|
46
47
|
activityProjectionUpdateNode = "ProjectionUpdateNode"
|
|
47
48
|
activityProjectionUpdateState = "ProjectionUpdateState"
|
|
@@ -72,6 +73,7 @@ type NodeRuntimeBinding struct {
|
|
|
72
73
|
type RunStateSnapshot struct {
|
|
73
74
|
Run run.Run `json:"run"`
|
|
74
75
|
RuntimeBindings []NodeRuntimeBinding `json:"runtimeBindings"`
|
|
76
|
+
Steps []run.StepEntry `json:"steps"`
|
|
75
77
|
}
|
|
76
78
|
|
|
77
79
|
type ReportStateQuery struct {
|
|
@@ -89,6 +91,7 @@ type workflowState struct {
|
|
|
89
91
|
run run.Run
|
|
90
92
|
bindings map[string]NodeRuntimeBinding
|
|
91
93
|
processed map[string]bool
|
|
94
|
+
steps []run.StepEntry
|
|
92
95
|
}
|
|
93
96
|
|
|
94
97
|
func (s *workflowState) snapshot() RunStateSnapshot {
|
|
@@ -101,7 +104,53 @@ func (s *workflowState) snapshot() RunStateSnapshot {
|
|
|
101
104
|
for _, node := range keys {
|
|
102
105
|
bindings = append(bindings, s.bindings[node])
|
|
103
106
|
}
|
|
104
|
-
|
|
107
|
+
steps := append([]run.StepEntry(nil), s.steps...)
|
|
108
|
+
return RunStateSnapshot{Run: s.run, RuntimeBindings: bindings, Steps: steps}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
func (s *workflowState) upsertStep(step run.StepEntry) {
|
|
112
|
+
for i := range s.steps {
|
|
113
|
+
if s.steps[i].Sequence == step.Sequence {
|
|
114
|
+
previous := s.steps[i]
|
|
115
|
+
if step.StartedAt == nil {
|
|
116
|
+
step.StartedAt = previous.StartedAt
|
|
117
|
+
}
|
|
118
|
+
if step.FinishedAt == nil {
|
|
119
|
+
step.FinishedAt = previous.FinishedAt
|
|
120
|
+
}
|
|
121
|
+
if step.Duration == 0 {
|
|
122
|
+
step.Duration = previous.Duration
|
|
123
|
+
}
|
|
124
|
+
if step.Message == "" {
|
|
125
|
+
step.Message = previous.Message
|
|
126
|
+
}
|
|
127
|
+
if step.Route == "" {
|
|
128
|
+
step.Route = previous.Route
|
|
129
|
+
}
|
|
130
|
+
if step.Runtime == "" {
|
|
131
|
+
step.Runtime = previous.Runtime
|
|
132
|
+
}
|
|
133
|
+
s.steps[i] = step
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
s.steps = append(s.steps, step)
|
|
138
|
+
sort.Slice(s.steps, func(i, j int) bool { return s.steps[i].Sequence < s.steps[j].Sequence })
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
func (s *workflowState) updateLatestStep(status run.StepStatus, message string) {
|
|
142
|
+
for i := len(s.steps) - 1; i >= 0; i-- {
|
|
143
|
+
if s.steps[i].Status != run.StepRunning && s.steps[i].Status != run.StepWaiting && s.steps[i].Status != run.StepBlocked {
|
|
144
|
+
continue
|
|
145
|
+
}
|
|
146
|
+
step := s.steps[i]
|
|
147
|
+
step.Status = status
|
|
148
|
+
if message != "" {
|
|
149
|
+
step.Message = message
|
|
150
|
+
}
|
|
151
|
+
s.upsertStep(step)
|
|
152
|
+
return
|
|
153
|
+
}
|
|
105
154
|
}
|
|
106
155
|
|
|
107
156
|
var temporalActivityOptions = temporalworkflow.ActivityOptions{
|
|
@@ -254,6 +303,23 @@ func runGraph(ctx temporalworkflow.Context, start run.Start, state *workflowStat
|
|
|
254
303
|
return err
|
|
255
304
|
}
|
|
256
305
|
|
|
306
|
+
// The timeline is a derived display projection. Sequence is deterministic
|
|
307
|
+
// for Temporal replay and is never consulted for routing.
|
|
308
|
+
stepSequence := int64(0)
|
|
309
|
+
target, err := wf.StartTarget()
|
|
310
|
+
if err != nil {
|
|
311
|
+
return err
|
|
312
|
+
}
|
|
313
|
+
startStarted := temporalworkflow.Now(ctx).UTC()
|
|
314
|
+
startStep := run.StepEntry{RunID: start.ID, Sequence: stepSequence, Node: "start", NodeType: "lifecycle",
|
|
315
|
+
Status: run.StepRunning, StartedAt: &startStarted, Depth: 1}
|
|
316
|
+
if _, err := retryActivity(ctx, state, work, "start", func() (struct{}, error) {
|
|
317
|
+
return executeActivity[struct{}](ctx, activityProjectionUpsertStep, startStep)
|
|
318
|
+
}); err != nil {
|
|
319
|
+
return err
|
|
320
|
+
}
|
|
321
|
+
state.upsertStep(startStep)
|
|
322
|
+
|
|
257
323
|
startNode := wf.Nodes["start"]
|
|
258
324
|
if _, err := retryActivity(ctx, state, work, "start", func() (struct{}, error) {
|
|
259
325
|
return executeActivity[struct{}](ctx, activityApplyTaskConfig, work, "start", (*task.Mailbox)(nil), mergeTaskConfig(wf.TaskConfig, startNode.TaskConfig))
|
|
@@ -265,12 +331,19 @@ func runGraph(ctx temporalworkflow.Context, start run.Start, state *workflowStat
|
|
|
265
331
|
}); err != nil {
|
|
266
332
|
return err
|
|
267
333
|
}
|
|
268
|
-
|
|
269
|
-
|
|
334
|
+
startFinished := temporalworkflow.Now(ctx).UTC()
|
|
335
|
+
startStep.Status, startStep.FinishedAt, startStep.Route = run.StepSucceeded, &startFinished, target
|
|
336
|
+
startStep.StartedAt = &startStarted
|
|
337
|
+
if _, err := retryActivity(ctx, state, work, "start", func() (struct{}, error) {
|
|
338
|
+
return executeActivity[struct{}](ctx, activityProjectionUpsertStep, startStep)
|
|
339
|
+
}); err != nil {
|
|
270
340
|
return err
|
|
271
341
|
}
|
|
342
|
+
state.upsertStep(startStep)
|
|
272
343
|
|
|
273
344
|
current := target
|
|
345
|
+
lastStepByNode := map[string]int64{}
|
|
346
|
+
lastDepthByNode := map[string]int{}
|
|
274
347
|
for current != "end" {
|
|
275
348
|
node := wf.Nodes[current]
|
|
276
349
|
var visitID identity.NodeVisitID
|
|
@@ -280,6 +353,26 @@ func runGraph(ctx temporalworkflow.Context, start run.Start, state *workflowStat
|
|
|
280
353
|
return err
|
|
281
354
|
}
|
|
282
355
|
visit := run.NodeVisitID(visitID)
|
|
356
|
+
stepSequence++
|
|
357
|
+
stepParent := int64(0)
|
|
358
|
+
stepDepth := 1
|
|
359
|
+
if previous, ok := lastStepByNode[current]; ok {
|
|
360
|
+
stepParent = previous
|
|
361
|
+
stepDepth = lastDepthByNode[current] + 1
|
|
362
|
+
}
|
|
363
|
+
stepStarted := temporalworkflow.Now(ctx).UTC()
|
|
364
|
+
step := run.StepEntry{RunID: start.ID, Sequence: stepSequence, Node: current,
|
|
365
|
+
NodeVisitID: visit, NodeType: string(node.Type), Status: run.StepRunning,
|
|
366
|
+
StartedAt: &stepStarted, ParentSequence: stepParent, Depth: stepDepth, Runtime: node.Agent}
|
|
367
|
+
if _, err := retryActivity(ctx, state, work, current, func() (struct{}, error) {
|
|
368
|
+
return executeActivity[struct{}](ctx, activityProjectionUpsertStep, step)
|
|
369
|
+
}); err != nil {
|
|
370
|
+
return err
|
|
371
|
+
}
|
|
372
|
+
state.upsertStep(step)
|
|
373
|
+
lastStepByNode[current] = stepSequence
|
|
374
|
+
lastDepthByNode[current] = stepDepth
|
|
375
|
+
|
|
283
376
|
runtime, err := retryActivity(ctx, state, work, current, func() (NodeRuntime, error) {
|
|
284
377
|
return executeActivity[NodeRuntime](ctx, activityLoadNodeRuntime, start.ID, current)
|
|
285
378
|
})
|
|
@@ -347,6 +440,7 @@ func runGraph(ctx temporalworkflow.Context, start run.Start, state *workflowStat
|
|
|
347
440
|
}
|
|
348
441
|
state.run.State = run.StateWaiting
|
|
349
442
|
state.run.UpdatedAt = temporalworkflow.Now(ctx)
|
|
443
|
+
state.updateLatestStep(run.StepWaiting, "waiting")
|
|
350
444
|
|
|
351
445
|
reportCh := temporalworkflow.GetSignalChannel(ctx, reportSignalName)
|
|
352
446
|
reconcileCh := temporalworkflow.GetSignalChannel(ctx, reconcileSignalName)
|
|
@@ -417,6 +511,20 @@ func runGraph(ctx temporalworkflow.Context, start run.Start, state *workflowStat
|
|
|
417
511
|
}); err != nil {
|
|
418
512
|
return err
|
|
419
513
|
}
|
|
514
|
+
stepStatus := run.StepSucceeded
|
|
515
|
+
stepMessage := accepted.Report.Summary.Completed
|
|
516
|
+
if accepted.Report.Status == domainworkflow.OutcomeFailure {
|
|
517
|
+
stepStatus = run.StepFailed
|
|
518
|
+
stepMessage = accepted.Report.Summary.IssuesDiscovered
|
|
519
|
+
}
|
|
520
|
+
stepFinished := temporalworkflow.Now(ctx).UTC()
|
|
521
|
+
step.Status, step.FinishedAt, step.Message, step.Route = stepStatus, &stepFinished, stepMessage, accepted.Report.NextStep
|
|
522
|
+
if _, err := retryActivity(ctx, state, work, current, func() (struct{}, error) {
|
|
523
|
+
return executeActivity[struct{}](ctx, activityProjectionUpsertStep, step)
|
|
524
|
+
}); err != nil {
|
|
525
|
+
return err
|
|
526
|
+
}
|
|
527
|
+
state.upsertStep(step)
|
|
420
528
|
|
|
421
529
|
if _, err := retryActivity(ctx, state, work, current, func() (struct{}, error) {
|
|
422
530
|
return executeActivity[struct{}](ctx, activityComment, start.Repo, run.CommentWork{
|
|
@@ -463,6 +571,17 @@ func runGraph(ctx temporalworkflow.Context, start run.Start, state *workflowStat
|
|
|
463
571
|
current = next
|
|
464
572
|
}
|
|
465
573
|
|
|
574
|
+
stepSequence++
|
|
575
|
+
endStarted := temporalworkflow.Now(ctx).UTC()
|
|
576
|
+
endStep := run.StepEntry{RunID: start.ID, Sequence: stepSequence, Node: "end", NodeType: "lifecycle",
|
|
577
|
+
Status: run.StepRunning, StartedAt: &endStarted, Depth: 1}
|
|
578
|
+
if _, err := retryActivity(ctx, state, work, "end", func() (struct{}, error) {
|
|
579
|
+
return executeActivity[struct{}](ctx, activityProjectionUpsertStep, endStep)
|
|
580
|
+
}); err != nil {
|
|
581
|
+
return err
|
|
582
|
+
}
|
|
583
|
+
state.upsertStep(endStep)
|
|
584
|
+
|
|
466
585
|
endNode := wf.Nodes["end"]
|
|
467
586
|
if _, err := retryActivity(ctx, state, work, "end", func() (struct{}, error) {
|
|
468
587
|
return executeActivity[struct{}](ctx, activityApplyTaskConfig, work, "end", (*task.Mailbox)(nil), mergeTaskConfig(wf.TaskConfig, endNode.TaskConfig))
|
|
@@ -494,6 +613,13 @@ func runGraph(ctx temporalworkflow.Context, start run.Start, state *workflowStat
|
|
|
494
613
|
}
|
|
495
614
|
}
|
|
496
615
|
now := temporalworkflow.Now(ctx).UTC()
|
|
616
|
+
endStep.Status, endStep.FinishedAt = run.StepSucceeded, &now
|
|
617
|
+
if _, err := retryActivity(ctx, state, work, "end", func() (struct{}, error) {
|
|
618
|
+
return executeActivity[struct{}](ctx, activityProjectionUpsertStep, endStep)
|
|
619
|
+
}); err != nil {
|
|
620
|
+
return err
|
|
621
|
+
}
|
|
622
|
+
state.upsertStep(endStep)
|
|
497
623
|
if _, err := retryActivity(ctx, state, work, "", func() (struct{}, error) {
|
|
498
624
|
return executeActivity[struct{}](ctx, activityProjectionUpdateState, start.ID, run.StateCompleted, "", &now)
|
|
499
625
|
}); err != nil {
|
|
@@ -566,6 +692,7 @@ func retryActivity[T any](ctx temporalworkflow.Context, state *workflowState, wo
|
|
|
566
692
|
return zero, stateErr
|
|
567
693
|
}
|
|
568
694
|
state.run.State = run.StateWaiting
|
|
695
|
+
state.updateLatestStep(run.StepWaiting, "waiting")
|
|
569
696
|
}
|
|
570
697
|
return result, nil
|
|
571
698
|
}
|
|
@@ -580,6 +707,7 @@ func retryActivity[T any](ctx temporalworkflow.Context, state *workflowState, wo
|
|
|
580
707
|
failure.Message = blockedMessage(work, node, failure.Message)
|
|
581
708
|
blocked = true
|
|
582
709
|
state.run.State = run.StateBlocked
|
|
710
|
+
state.updateLatestStep(run.StepBlocked, failure.Message)
|
|
583
711
|
if _, stateErr := retryProjectionActivity(ctx, state, work, node, func() (struct{}, error) {
|
|
584
712
|
return executeActivity[struct{}](ctx, activityProjectionUpdateState, work.RunID, run.StateBlocked, failure.Message, (*time.Time)(nil))
|
|
585
713
|
}); stateErr != nil {
|
|
@@ -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.10-alpha"
|
|
18
18
|
|
|
19
19
|
func TestBuildCommandArgv(t *testing.T) {
|
|
20
20
|
t.Setenv("RELAY_FLOW_HOME", "/var/lib/relay-flow-test")
|
|
@@ -71,12 +71,12 @@ func TestRenderPromptTemplatesExposeAllValues(t *testing.T) {
|
|
|
71
71
|
Node: "review", NodeType: workflow.NodeHITL, Agent: "build", NodeDescription: "Review it.",
|
|
72
72
|
NextSteps: "end (when: approved)", Mailbox: "PAY-234",
|
|
73
73
|
}
|
|
74
|
-
nudge := "nudge {{taskSystem}}|{{ticket}}|{{workflow}}|{{repo}}|{{node}}|{{nextSteps}}"
|
|
74
|
+
nudge := "nudge {{taskSystem}}|{{ticket}}|{{workflow}}|{{repo}}|{{node}}|{{mailbox}}|{{nextSteps}}"
|
|
75
75
|
initial, err := h.RenderPrompt(harness.PromptInitial, data, nudge)
|
|
76
76
|
if err != nil {
|
|
77
77
|
t.Fatal(err)
|
|
78
78
|
}
|
|
79
|
-
wantInitial := "initial linear|PAY-101|basicFlow|payments|review|hitl|build|Review it.|end (when: approved)|PAY-234\n\nReturn the complete report directly. Relay-flow will show a native TUI approval dialog after the report is valid. Do not use OpenCode's Question tool for relay-flow approval.\n\nnudge linear|PAY-101|basicFlow|payments|review|end (when: approved)"
|
|
79
|
+
wantInitial := "initial linear|PAY-101|basicFlow|payments|review|hitl|build|Review it.|end (when: approved)|PAY-234\n\nReturn the complete report directly. Relay-flow will show a native TUI approval dialog after the report is valid. Do not use OpenCode's Question tool for relay-flow approval.\n\nnudge linear|PAY-101|basicFlow|payments|review|PAY-234|end (when: approved)"
|
|
80
80
|
if initial != wantInitial {
|
|
81
81
|
t.Fatalf("initial prompt = %q, want %q", initial, wantInitial)
|
|
82
82
|
}
|
|
@@ -84,7 +84,7 @@ func TestRenderPromptTemplatesExposeAllValues(t *testing.T) {
|
|
|
84
84
|
if err != nil {
|
|
85
85
|
t.Fatal(err)
|
|
86
86
|
}
|
|
87
|
-
if want := "feedback PAY-234\n\nReturn the complete report directly. Relay-flow will show a native TUI approval dialog after the report is valid. Do not use OpenCode's Question tool for relay-flow approval.\n\nnudge linear|PAY-101|basicFlow|payments|review|end (when: approved)"; feedback != want {
|
|
87
|
+
if want := "feedback PAY-234\n\nReturn the complete report directly. Relay-flow will show a native TUI approval dialog after the report is valid. Do not use OpenCode's Question tool for relay-flow approval.\n\nnudge linear|PAY-101|basicFlow|payments|review|PAY-234|end (when: approved)"; feedback != want {
|
|
88
88
|
t.Fatalf("feedback prompt = %q, want %q", feedback, want)
|
|
89
89
|
}
|
|
90
90
|
}
|
|
@@ -23,13 +23,13 @@ func TestPiRenderPromptSubstitutesInitialAndFeedbackData(t *testing.T) {
|
|
|
23
23
|
NextSteps: "review (when: ready)",
|
|
24
24
|
Mailbox: "PAY-234",
|
|
25
25
|
}
|
|
26
|
-
nudge := "nudge {{taskSystem}}|{{ticket}}|{{workflow}}|{{repo}}|{{node}}|{{nextSteps}}"
|
|
26
|
+
nudge := "nudge {{taskSystem}}|{{ticket}}|{{workflow}}|{{repo}}|{{node}}|{{mailbox}}|{{nextSteps}}"
|
|
27
27
|
|
|
28
28
|
initial, err := h.RenderPrompt(harness.PromptInitial, data, nudge)
|
|
29
29
|
if err != nil {
|
|
30
30
|
t.Fatalf("RenderPrompt(initial): %v", err)
|
|
31
31
|
}
|
|
32
|
-
wantInitial := "Task system: jira\nUse the jira tools to read the parent ticket PAY-101.\n\nYour mailbox is PAY-234. Read its description and comments for node instructions and feedback.\n\nKeep the summary brief, and make the feedback as detailed and actionable as possible for the next agent.\n\nnudge jira|PAY-101|basicFlow|payments|implement|review (when: ready)"
|
|
32
|
+
wantInitial := "Task system: jira\nUse the jira tools to read the parent ticket PAY-101.\n\nYour mailbox is PAY-234. Read its description and comments for node instructions and feedback.\n\nKeep the summary brief, and make the feedback as detailed and actionable as possible for the next agent.\n\nnudge jira|PAY-101|basicFlow|payments|implement|PAY-234|review (when: ready)"
|
|
33
33
|
if initial != wantInitial {
|
|
34
34
|
t.Fatalf("initial prompt = %q, want %q", initial, wantInitial)
|
|
35
35
|
}
|
|
@@ -38,7 +38,7 @@ func TestPiRenderPromptSubstitutesInitialAndFeedbackData(t *testing.T) {
|
|
|
38
38
|
if err != nil {
|
|
39
39
|
t.Fatalf("RenderPrompt(feedback): %v", err)
|
|
40
40
|
}
|
|
41
|
-
wantFeedback := "New feedback was added to the comments section of your mailbox subtask PAY-234. Read it.\n\nnudge jira|PAY-101|basicFlow|payments|implement|review (when: ready)"
|
|
41
|
+
wantFeedback := "New feedback was added to the comments section of your mailbox subtask PAY-234. Read it.\n\nnudge jira|PAY-101|basicFlow|payments|implement|PAY-234|review (when: ready)"
|
|
42
42
|
if feedback != wantFeedback {
|
|
43
43
|
t.Fatalf("feedback prompt = %q, want %q", feedback, wantFeedback)
|
|
44
44
|
}
|
package/internal/repo/service.go
CHANGED
|
@@ -4,6 +4,7 @@ import (
|
|
|
4
4
|
"context"
|
|
5
5
|
"fmt"
|
|
6
6
|
"path/filepath"
|
|
7
|
+
"strings"
|
|
7
8
|
|
|
8
9
|
"github.com/rajpopat27/relay-flow/internal/config"
|
|
9
10
|
"github.com/rajpopat27/relay-flow/internal/harness"
|
|
@@ -70,6 +71,25 @@ func (s *Service) Discover(ctx context.Context) ([]runner.RepoCandidate, error)
|
|
|
70
71
|
return s.runner.DiscoverRepos(ctx)
|
|
71
72
|
}
|
|
72
73
|
|
|
74
|
+
// EnsureRepo makes the runner resource for a repository available without
|
|
75
|
+
// changing relay-flow's machine configuration. Runner adapters that expose
|
|
76
|
+
// RepoRegistrar perform their idempotent external provisioning; other
|
|
77
|
+
// runners validate the repository through the existing read-only contract.
|
|
78
|
+
func (s *Service) EnsureRepo(ctx context.Context, name, path string) error {
|
|
79
|
+
name = strings.TrimSpace(name)
|
|
80
|
+
path = strings.TrimSpace(path)
|
|
81
|
+
if name == "" {
|
|
82
|
+
return fmt.Errorf("repo: name is required")
|
|
83
|
+
}
|
|
84
|
+
if path == "" {
|
|
85
|
+
return fmt.Errorf("repo %q: path is required", name)
|
|
86
|
+
}
|
|
87
|
+
if registrar, ok := s.runner.(runner.RepoRegistrar); ok {
|
|
88
|
+
return registrar.EnsureRepo(ctx, name, path)
|
|
89
|
+
}
|
|
90
|
+
return s.runner.ValidateRepo(ctx, name, path)
|
|
91
|
+
}
|
|
92
|
+
|
|
73
93
|
// RequiredRepoKeys delegates to the task factory's method of the same name.
|
|
74
94
|
func (s *Service) RequiredRepoKeys() []string {
|
|
75
95
|
keys, err := task.RequiredRepoKeys(s.taskPlugin)
|
|
@@ -95,9 +115,14 @@ type RegisterInput struct {
|
|
|
95
115
|
// connectivity, duplicate names, canonical paths, and the task plugin's
|
|
96
116
|
// registration identity before atomically writing machine config.
|
|
97
117
|
func (s *Service) Register(ctx context.Context, input RegisterInput) (Info, error) {
|
|
118
|
+
input.Name = strings.TrimSpace(input.Name)
|
|
119
|
+
input.Path = strings.TrimSpace(input.Path)
|
|
98
120
|
if input.Name == "" {
|
|
99
121
|
return Info{}, fmt.Errorf("repo: name is required")
|
|
100
122
|
}
|
|
123
|
+
if input.Path == "" {
|
|
124
|
+
return Info{}, fmt.Errorf("repo %q: path is required", input.Name)
|
|
125
|
+
}
|
|
101
126
|
cfg, err := config.LoadMachine(s.cfgPath)
|
|
102
127
|
if err != nil {
|
|
103
128
|
return Info{}, err
|
|
@@ -154,8 +179,10 @@ func (s *Service) Register(ctx context.Context, input RegisterInput) (Info, erro
|
|
|
154
179
|
return Info{}, fmt.Errorf("repo %q: task registration identity already used by repo %q", input.Name, name)
|
|
155
180
|
}
|
|
156
181
|
}
|
|
157
|
-
// Runner validates the repo
|
|
158
|
-
|
|
182
|
+
// Runner validates or provisions the repo, depending on the adapter
|
|
183
|
+
// capability. Provisioning is idempotent so explicit registration and the
|
|
184
|
+
// interactive Add action share one runner-owned path.
|
|
185
|
+
if err := s.EnsureRepo(ctx, input.Name, input.Path); err != nil {
|
|
159
186
|
return Info{}, fmt.Errorf("repo %q: runner validation: %w", input.Name, err)
|
|
160
187
|
}
|
|
161
188
|
// Task-system connectivity: construct the repo-bound System.
|
|
@@ -245,5 +272,9 @@ func canonicalPath(p string) string {
|
|
|
245
272
|
if err != nil {
|
|
246
273
|
return filepath.Clean(p)
|
|
247
274
|
}
|
|
248
|
-
|
|
275
|
+
abs = filepath.Clean(abs)
|
|
276
|
+
if resolved, err := filepath.EvalSymlinks(abs); err == nil {
|
|
277
|
+
return filepath.Clean(resolved)
|
|
278
|
+
}
|
|
279
|
+
return abs
|
|
249
280
|
}
|
|
@@ -23,6 +23,17 @@ type fakeRunnerDiscovery struct {
|
|
|
23
23
|
validErr error
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
type fakeRunnerRegistrar struct {
|
|
27
|
+
*fakeRunnerDiscovery
|
|
28
|
+
ensureErr error
|
|
29
|
+
ensureCalls []runner.RepoCandidate
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
func (f *fakeRunnerRegistrar) EnsureRepo(_ context.Context, name, path string) error {
|
|
33
|
+
f.ensureCalls = append(f.ensureCalls, runner.RepoCandidate{Name: name, Path: path})
|
|
34
|
+
return f.ensureErr
|
|
35
|
+
}
|
|
36
|
+
|
|
26
37
|
func (f *fakeRunnerDiscovery) DiscoverRepos(context.Context) ([]runner.RepoCandidate, error) {
|
|
27
38
|
return f.candidates, nil
|
|
28
39
|
}
|
|
@@ -228,6 +239,17 @@ func TestRegisterValidatesRunnerRepo(t *testing.T) {
|
|
|
228
239
|
}
|
|
229
240
|
}
|
|
230
241
|
|
|
242
|
+
func TestRegisterUsesRunnerRepoProvisioningCapability(t *testing.T) {
|
|
243
|
+
rn := &fakeRunnerRegistrar{fakeRunnerDiscovery: &fakeRunnerDiscovery{validErr: errInvalidRepo{}}}
|
|
244
|
+
fx := newServiceFixture(t, rn)
|
|
245
|
+
if _, err := fx.svc.Register(context.Background(), repo.RegisterInput{Name: "payments", Path: "/srv/payments", TaskConfig: config.RawValues{"project": "P", "component": "c"}}); err != nil {
|
|
246
|
+
t.Fatal(err)
|
|
247
|
+
}
|
|
248
|
+
if len(rn.ensureCalls) != 1 || rn.ensureCalls[0] != (runner.RepoCandidate{Name: "payments", Path: "/srv/payments"}) {
|
|
249
|
+
t.Fatalf("EnsureRepo calls = %+v", rn.ensureCalls)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
231
253
|
func TestRegisterValidatesTaskConnectivity(t *testing.T) {
|
|
232
254
|
// The task factory's New is invoked to validate connectivity; its error
|
|
233
255
|
// must reject registration before persisting.
|