relay-flow 0.2.8-alpha → 0.2.10-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +76 -35
  2. package/cmd/relay-flow/commands_test.go +117 -8
  3. package/cmd/relay-flow/main.go +391 -83
  4. package/cmd/relay-flow/observability_test.go +204 -0
  5. package/cmd/relay-flow/onboarding.go +344 -0
  6. package/cmd/relay-flow/onboarding_test.go +515 -0
  7. package/cmd/relay-flow/render.go +629 -0
  8. package/cmd/relay-flow/repo_registration.go +219 -0
  9. package/cmd/relay-flow/serve.go +35 -1
  10. package/go.mod +12 -8
  11. package/go.sum +24 -17
  12. package/internal/execution/goworkflows/activities.go +9 -0
  13. package/internal/execution/goworkflows/engine.go +15 -0
  14. package/internal/execution/goworkflows/engine_test.go +3 -3
  15. package/internal/execution/goworkflows/interpreter.go +86 -3
  16. package/internal/execution/goworkflows/projection.go +16 -0
  17. package/internal/execution/projection/detail_test.go +259 -0
  18. package/internal/execution/projection/projection.go +290 -6
  19. package/internal/execution/projection/projection_test.go +3 -2
  20. package/internal/execution/temporal/activities.go +9 -0
  21. package/internal/execution/temporal/engine.go +13 -0
  22. package/internal/execution/temporal/interpreter.go +131 -3
  23. package/internal/execution/temporal/operations.go +174 -0
  24. package/internal/execution/temporal/operations_test.go +36 -0
  25. package/internal/harness/opencode/opencode_test.go +4 -4
  26. package/internal/harness/opencode/repo_setup.go +1 -1
  27. package/internal/harness/pi/prompt_test.go +3 -3
  28. package/internal/repo/service.go +34 -3
  29. package/internal/repo/service_test.go +22 -0
  30. package/internal/run/detail.go +277 -0
  31. package/internal/run/detail_test.go +71 -0
  32. package/internal/run/run.go +4 -0
  33. package/internal/runner/herdr/herdr.go +103 -4
  34. package/internal/runner/herdr/herdr_test.go +72 -2
  35. package/internal/runner/herdr/herdrcli/contract.go +12 -3
  36. package/internal/runner/herdr/herdrcli/herdrcli_test.go +17 -2
  37. package/internal/runner/herdr/herdrcli/operations.go +16 -0
  38. package/internal/runner/herdr/herdrcli/testdata/strict-herdr.sh +8 -0
  39. package/internal/runner/herdr/herdrcli/testdata/workspace-create.json +1 -0
  40. package/internal/runner/orca/orca.go +68 -9
  41. package/internal/runner/orca/orca_test.go +134 -2
  42. package/internal/runner/orca/orcacli/orcacli.go +21 -1
  43. package/internal/runner/orca/orcacli/orcacli_test.go +22 -0
  44. package/internal/runner/orca/orcacli/testdata/strict-orca.sh +5 -0
  45. package/internal/runner/runner.go +9 -0
  46. package/internal/server/api_test.go +19 -0
  47. package/internal/server/client.go +49 -0
  48. package/internal/server/fixture_test.go +7 -0
  49. package/internal/server/observability.go +109 -0
  50. package/internal/server/observability_test.go +60 -0
  51. package/internal/server/server.go +77 -0
  52. package/internal/task/jira/testdata/jira_search_issues.json +4 -4
  53. package/internal/workflow/workflow.go +4 -1
  54. package/internal/workflow/workflow_test.go +4 -4
  55. package/package.json +1 -1
@@ -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
- return RunStateSnapshot{Run: s.run, RuntimeBindings: bindings}
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
- target, err := wf.StartTarget()
269
- if err != nil {
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.8-alpha"
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
  }
@@ -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.8-alpha"
13
+ const relayFlowPlugin = "relay-flow-plugin@0.2.10-alpha"
14
14
 
15
15
  type jsoncToken struct {
16
16
  kind byte
@@ -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
  }
@@ -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
- if err := s.runner.ValidateRepo(ctx, input.Name, input.Path); err != nil {
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
- return filepath.Clean(abs)
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.