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
@@ -0,0 +1,259 @@
1
+ package projection_test
2
+
3
+ import (
4
+ "context"
5
+ "testing"
6
+ "time"
7
+
8
+ "github.com/rajpopat27/relay-flow/internal/run"
9
+ )
10
+
11
+ func TestLegacyNullCreationAndDurationRemainUnavailable(t *testing.T) {
12
+ ctx := context.Background()
13
+ p, db := openProjection(t)
14
+ now := time.Now().UTC()
15
+ if _, err := db.Exec(`INSERT INTO relay_runs (id, repo, workflow, ticket_id, ticket_key, state, started_at, updated_at) VALUES (?, 'repo', 'workflow', 'T-LEGACY', 'T-LEGACY', 'waiting', ?, ?)`, "legacy-null", now, now); err != nil {
16
+ t.Fatal(err)
17
+ }
18
+ detail, err := p.GetDetail(ctx, "legacy-null")
19
+ if err != nil {
20
+ t.Fatal(err)
21
+ }
22
+ if detail.CreatedAt != nil {
23
+ t.Fatalf("legacy CreatedAt = %v, want nil", detail.CreatedAt)
24
+ }
25
+ if err := p.UpsertStep(ctx, run.StepEntry{RunID: "legacy-null", Sequence: 1, Node: "coding", Status: run.StepRunning}); err != nil {
26
+ t.Fatal(err)
27
+ }
28
+ steps, err := p.ListSteps(ctx, "legacy-null")
29
+ if err != nil {
30
+ t.Fatal(err)
31
+ }
32
+ if len(steps) != 1 || steps[0].DurationKnown {
33
+ t.Fatalf("legacy unknown duration = %#v", steps)
34
+ }
35
+ }
36
+
37
+ func TestStepProjectionIsIdempotentAndDerivesDetail(t *testing.T) {
38
+ ctx := context.Background()
39
+ p, _ := openProjection(t)
40
+ start := projectionStart("detail-run", "PAY-DETAIL")
41
+ started := time.Now().UTC().Add(-2 * time.Minute)
42
+ if err := p.InsertStart(ctx, start, started); err != nil {
43
+ t.Fatal(err)
44
+ }
45
+ finished := started.Add(time.Minute)
46
+ step := run.StepEntry{
47
+ RunID: start.ID, Sequence: 1, Node: "coding", NodeVisitID: "visit-1", NodeType: "agent",
48
+ Status: run.StepSucceeded, StartedAt: &started, FinishedAt: &finished,
49
+ Message: "report accepted", Route: "end", Runtime: "coder", Resource: "harness",
50
+ }
51
+ if err := p.UpsertStep(ctx, step); err != nil {
52
+ t.Fatal(err)
53
+ }
54
+ if err := p.UpsertStep(ctx, step); err != nil {
55
+ t.Fatal(err)
56
+ }
57
+ steps, err := p.ListSteps(ctx, start.ID)
58
+ if err != nil {
59
+ t.Fatal(err)
60
+ }
61
+ if len(steps) != 1 || steps[0].NodeVisitID != step.NodeVisitID || steps[0].Route != "end" || steps[0].Duration <= 0 {
62
+ t.Fatalf("steps = %#v", steps)
63
+ }
64
+ detail, err := p.GetDetail(ctx, start.ID)
65
+ if err != nil {
66
+ t.Fatal(err)
67
+ }
68
+ if detail.Progress.Completed != 1 || detail.Progress.Total != 1 || detail.ResourcesDuration.Harness <= 0 {
69
+ t.Fatalf("detail = %#v", detail)
70
+ }
71
+ if detail.Conditions.Completed {
72
+ t.Fatal("starting run was reported completed")
73
+ }
74
+ }
75
+
76
+ func TestStepUpsertDoesNotRegressTerminalVisit(t *testing.T) {
77
+ ctx := context.Background()
78
+ p, _ := openProjection(t)
79
+ start := projectionStart("monotonic-run", "PAY-MONOTONIC")
80
+ if err := p.InsertStart(ctx, start, time.Now().UTC()); err != nil {
81
+ t.Fatal(err)
82
+ }
83
+ finished := time.Now().UTC()
84
+ terminal := run.StepEntry{RunID: start.ID, Sequence: 1, Node: "coding", NodeVisitID: "visit-1", Status: run.StepSucceeded, FinishedAt: &finished, Message: "accepted", Route: "end"}
85
+ if err := p.UpsertStep(ctx, terminal); err != nil {
86
+ t.Fatal(err)
87
+ }
88
+ stale := terminal
89
+ stale.Status, stale.Message, stale.Route = run.StepRunning, "stale retry", "coding"
90
+ if err := p.UpsertStep(ctx, stale); err != nil {
91
+ t.Fatal(err)
92
+ }
93
+ steps, err := p.ListSteps(ctx, start.ID)
94
+ if err != nil {
95
+ t.Fatal(err)
96
+ }
97
+ if len(steps) != 1 || steps[0].Status != run.StepSucceeded || steps[0].Message != "accepted" || steps[0].Route != "end" {
98
+ t.Fatalf("stale write regressed terminal step: %#v", steps)
99
+ }
100
+ }
101
+
102
+ func TestBranchLoopVisitsRemainDistinctAndNested(t *testing.T) {
103
+ ctx := context.Background()
104
+ p, _ := openProjection(t)
105
+ start := projectionStart("branch-loop", "PAY-BRANCH")
106
+ if err := p.InsertStart(ctx, start, time.Now().UTC()); err != nil {
107
+ t.Fatal(err)
108
+ }
109
+ steps := []run.StepEntry{
110
+ {RunID: start.ID, Sequence: 1, Node: "coding", NodeVisitID: "visit-coding-1", Status: run.StepSucceeded, Depth: 1},
111
+ {RunID: start.ID, Sequence: 2, Node: "review", NodeVisitID: "visit-review-1", Status: run.StepSucceeded, Depth: 1},
112
+ {RunID: start.ID, Sequence: 3, Node: "coding", NodeVisitID: "visit-coding-2", ParentSequence: 1, Status: run.StepWaiting, Depth: 2},
113
+ }
114
+ for _, step := range steps {
115
+ if err := p.UpsertStep(ctx, step); err != nil {
116
+ t.Fatal(err)
117
+ }
118
+ }
119
+ got, err := p.ListSteps(ctx, start.ID)
120
+ if err != nil {
121
+ t.Fatal(err)
122
+ }
123
+ if len(got) != len(steps) || got[2].NodeVisitID != "visit-coding-2" || got[2].ParentSequence != 1 || got[2].Depth != 2 {
124
+ t.Fatalf("branch/loop steps = %#v", got)
125
+ }
126
+ }
127
+
128
+ func TestStepProjectionFailureDoesNotBlockAuthoritativeRunState(t *testing.T) {
129
+ ctx := context.Background()
130
+ p, db := openProjection(t)
131
+ start := projectionStart("projection-failure", "PAY-PROJECTION")
132
+ if err := p.InsertStart(ctx, start, time.Now().UTC()); err != nil {
133
+ t.Fatal(err)
134
+ }
135
+ if _, err := db.Exec(`DROP TABLE relay_run_steps`); err != nil {
136
+ t.Fatal(err)
137
+ }
138
+ if err := p.UpdateState(ctx, start.ID, run.StateWaiting, "", nil); err != nil {
139
+ t.Fatalf("authoritative state update failed with missing display table: %v", err)
140
+ }
141
+ got, err := p.Get(ctx, start.ID)
142
+ if err != nil {
143
+ t.Fatal(err)
144
+ }
145
+ if got.State != run.StateWaiting {
146
+ t.Fatalf("state = %s, want waiting", got.State)
147
+ }
148
+ }
149
+
150
+ func TestTerminalStepTimingIsStableAcrossLaterRunFinalization(t *testing.T) {
151
+ ctx := context.Background()
152
+ p, _ := openProjection(t)
153
+ start := projectionStart("stable-terminal", "PAY-STABLE")
154
+ t0 := time.Now().UTC().Add(-2 * time.Minute)
155
+ t1 := t0.Add(20 * time.Second)
156
+ if err := p.InsertStart(ctx, start, t0); err != nil {
157
+ t.Fatal(err)
158
+ }
159
+ if err := p.UpsertStep(ctx, run.StepEntry{RunID: start.ID, Sequence: 1, Node: "coding", Status: run.StepSucceeded, StartedAt: &t0, FinishedAt: &t1}); err != nil {
160
+ t.Fatal(err)
161
+ }
162
+ before, err := p.ListSteps(ctx, start.ID)
163
+ if err != nil {
164
+ t.Fatal(err)
165
+ }
166
+ if len(before) != 1 || before[0].FinishedAt == nil || before[0].Duration <= 0 {
167
+ t.Fatalf("initial terminal step = %#v", before)
168
+ }
169
+ finished := t1.Add(time.Minute)
170
+ if err := p.UpdateState(ctx, start.ID, run.StateCanceled, "canceled", &finished); err != nil {
171
+ t.Fatal(err)
172
+ }
173
+ if err := p.UpdateState(ctx, start.ID, run.StateCanceled, "canceled", &finished); err != nil {
174
+ t.Fatal(err)
175
+ }
176
+ after, err := p.ListSteps(ctx, start.ID)
177
+ if err != nil {
178
+ t.Fatal(err)
179
+ }
180
+ if len(after) != 1 || !after[0].FinishedAt.Equal(*before[0].FinishedAt) || after[0].Duration != before[0].Duration {
181
+ t.Fatalf("terminal timing changed after run finalization: before=%#v after=%#v", before, after)
182
+ }
183
+ }
184
+
185
+ func TestCompletedRunFinalizesActiveStepWhenFinalUpsertIsMissing(t *testing.T) {
186
+ ctx := context.Background()
187
+ p, _ := openProjection(t)
188
+ start := projectionStart("complete-detail", "PAY-COMPLETE-DETAIL")
189
+ started := time.Now().UTC().Add(-time.Minute)
190
+ if err := p.InsertStart(ctx, start, started); err != nil {
191
+ t.Fatal(err)
192
+ }
193
+ if err := p.UpsertStep(ctx, run.StepEntry{RunID: start.ID, Sequence: 1, Node: "end", Status: run.StepRunning, StartedAt: &started}); err != nil {
194
+ t.Fatal(err)
195
+ }
196
+ finished := time.Now().UTC()
197
+ if err := p.UpdateState(ctx, start.ID, run.StateCompleted, "", &finished); err != nil {
198
+ t.Fatal(err)
199
+ }
200
+ steps, err := p.ListSteps(ctx, start.ID)
201
+ if err != nil {
202
+ t.Fatal(err)
203
+ }
204
+ if len(steps) != 1 || steps[0].Status != run.StepSucceeded || steps[0].FinishedAt == nil || steps[0].Duration <= 0 {
205
+ t.Fatalf("completed step = %#v", steps)
206
+ }
207
+ }
208
+
209
+ func TestCanceledRunFinalizesCurrentStepTiming(t *testing.T) {
210
+ ctx := context.Background()
211
+ p, _ := openProjection(t)
212
+ start := projectionStart("cancel-detail", "PAY-CANCEL-DETAIL")
213
+ started := time.Now().UTC().Add(-time.Minute)
214
+ if err := p.InsertStart(ctx, start, started); err != nil {
215
+ t.Fatal(err)
216
+ }
217
+ if err := p.UpsertStep(ctx, run.StepEntry{RunID: start.ID, Sequence: 1, Node: "coding", Status: run.StepRunning, StartedAt: &started}); err != nil {
218
+ t.Fatal(err)
219
+ }
220
+ if err := p.UpdateState(ctx, start.ID, run.StateCanceling, "operator canceled", nil); err != nil {
221
+ t.Fatal(err)
222
+ }
223
+ finished := time.Now().UTC()
224
+ if err := p.UpdateState(ctx, start.ID, run.StateCanceled, "operator canceled", &finished); err != nil {
225
+ t.Fatal(err)
226
+ }
227
+ steps, err := p.ListSteps(ctx, start.ID)
228
+ if err != nil {
229
+ t.Fatal(err)
230
+ }
231
+ if len(steps) != 1 || steps[0].Status != run.StepCanceled || steps[0].FinishedAt == nil || steps[0].Duration <= 0 {
232
+ t.Fatalf("canceled step = %#v", steps)
233
+ }
234
+ }
235
+
236
+ func TestSetupConflictMarksCurrentStepBlocked(t *testing.T) {
237
+ ctx := context.Background()
238
+ p, _ := openProjection(t)
239
+ start := projectionStart("setup-conflict", "PAY-SETUP")
240
+ if err := p.InsertStart(ctx, start, time.Now().UTC()); err != nil {
241
+ t.Fatal(err)
242
+ }
243
+ if err := p.UpsertStep(ctx, run.StepEntry{RunID: start.ID, Sequence: 1, Node: "coding", Status: run.StepRunning}); err != nil {
244
+ t.Fatal(err)
245
+ }
246
+ if err := p.UpdateState(ctx, start.ID, run.StateBlocked, "runner setup conflict", nil); err != nil {
247
+ t.Fatal(err)
248
+ }
249
+ steps, err := p.ListSteps(ctx, start.ID)
250
+ if err != nil {
251
+ t.Fatal(err)
252
+ }
253
+ if len(steps) != 1 || steps[0].Status != run.StepBlocked || steps[0].Message != "runner setup conflict" {
254
+ t.Fatalf("blocked setup step = %#v", steps)
255
+ }
256
+ if steps[0].DurationKnown {
257
+ t.Fatal("unknown setup duration was fabricated as known")
258
+ }
259
+ }
@@ -5,6 +5,7 @@ import (
5
5
  "database/sql"
6
6
  "errors"
7
7
  "fmt"
8
+ "log/slog"
8
9
  "os"
9
10
  "strings"
10
11
  "sync"
@@ -117,6 +118,7 @@ CREATE TABLE IF NOT EXISTS relay_runs (
117
118
  workflow TEXT NOT NULL,
118
119
  ticket_id TEXT NOT NULL,
119
120
  ticket_key TEXT NOT NULL,
121
+ created_at DATETIME,
120
122
  state TEXT NOT NULL,
121
123
  current_node TEXT,
122
124
  current_node_visit_id TEXT,
@@ -158,6 +160,28 @@ CREATE TABLE IF NOT EXISTS relay_node_sessions (
158
160
  PRIMARY KEY (run_id, node, session_id),
159
161
  FOREIGN KEY (run_id) REFERENCES relay_runs(id) ON DELETE CASCADE
160
162
  );
163
+ CREATE TABLE IF NOT EXISTS relay_run_steps (
164
+ run_id TEXT NOT NULL,
165
+ sequence INTEGER NOT NULL,
166
+ node TEXT NOT NULL,
167
+ node_visit_id TEXT,
168
+ node_type TEXT,
169
+ parent_sequence INTEGER,
170
+ depth INTEGER,
171
+ status TEXT NOT NULL,
172
+ started_at DATETIME,
173
+ finished_at DATETIME,
174
+ duration_ns INTEGER,
175
+ message TEXT,
176
+ selected_route TEXT,
177
+ runtime TEXT,
178
+ resource TEXT,
179
+ PRIMARY KEY (run_id, sequence),
180
+ FOREIGN KEY (run_id) REFERENCES relay_runs(id) ON DELETE CASCADE
181
+ );
182
+ CREATE INDEX IF NOT EXISTS relay_run_steps_run_order ON relay_run_steps (run_id, sequence);
183
+ CREATE UNIQUE INDEX IF NOT EXISTS relay_run_steps_run_visit ON relay_run_steps (run_id, node_visit_id)
184
+ WHERE node_visit_id IS NOT NULL AND node_visit_id <> '';
161
185
  `
162
186
 
163
187
  func (p *RunProjection) migrate() error {
@@ -170,6 +194,7 @@ func (p *RunProjection) migrate() error {
170
194
  "retry_error": "TEXT",
171
195
  "retry_attempt": "INTEGER",
172
196
  "next_retry_at": "DATETIME",
197
+ "created_at": "DATETIME",
173
198
  } {
174
199
  var count int
175
200
  if err := p.DB.QueryRow(`SELECT COUNT(1) FROM pragma_table_info('relay_runs') WHERE name = ?`, name).Scan(&count); err != nil {
@@ -190,10 +215,24 @@ func (p *RunProjection) migrate() error {
190
215
  if _, err := p.DB.Exec(`UPDATE relay_runs SET attempt_id = 1 WHERE attempt_id IS NULL OR attempt_id = 0`); err != nil {
191
216
  return err
192
217
  }
218
+ for name, definition := range map[string]string{
219
+ "parent_sequence": "INTEGER",
220
+ "depth": "INTEGER",
221
+ } {
222
+ var count int
223
+ if err := p.DB.QueryRow(`SELECT COUNT(1) FROM pragma_table_info('relay_run_steps') WHERE name = ?`, name).Scan(&count); err != nil {
224
+ return err
225
+ }
226
+ if count == 0 {
227
+ if _, err := p.DB.Exec(`ALTER TABLE relay_run_steps ADD COLUMN ` + name + ` ` + definition); err != nil {
228
+ return err
229
+ }
230
+ }
231
+ }
193
232
  return nil
194
233
  }
195
234
 
196
- var errRunNotFound = errors.New("run not found")
235
+ var errRunNotFound = run.ErrNotFound
197
236
  var errNodeRuntimeNotFound = errors.New("node runtime not found")
198
237
 
199
238
  // IsNotFound reports a missing projection row.
@@ -209,24 +248,86 @@ func (p *RunProjection) insertStart(ctx context.Context, s run.Start, now time.T
209
248
  attemptID = 1
210
249
  }
211
250
  _, err := p.DB.ExecContext(ctx, `
212
- INSERT INTO relay_runs (id, logical_run_id, attempt_id, repo, workflow, ticket_id, ticket_key, state, started_at, updated_at)
213
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
251
+ INSERT INTO relay_runs (id, logical_run_id, attempt_id, repo, workflow, ticket_id, ticket_key, created_at, state, started_at, updated_at)
252
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
214
253
  ON CONFLICT(id) DO NOTHING`,
215
254
  string(s.ID), string(logicalID), int64(attemptID), s.Repo, s.Workflow.Name, s.Ticket.ID, s.Ticket.Key,
216
- string(run.StateStarting), now, now)
255
+ now, string(run.StateStarting), now, now)
217
256
  return err
218
257
  }
219
258
 
220
259
  func (p *RunProjection) updateState(ctx context.Context, id run.ID, state run.State, lastErr string, finished *time.Time) error {
221
260
  terminal := state == run.StateCompleted || state == run.StateCanceled
261
+ now := time.Now().UTC()
222
262
  _, err := p.DB.ExecContext(ctx, `
223
263
  UPDATE relay_runs SET state = ?, last_error = ?, updated_at = ?, finished_at = COALESCE(?, finished_at),
224
264
  retry_error = CASE WHEN ? THEN NULL ELSE retry_error END,
225
265
  retry_attempt = CASE WHEN ? THEN NULL ELSE retry_attempt END,
226
266
  next_retry_at = CASE WHEN ? THEN NULL ELSE next_retry_at END
227
267
  WHERE id = ?`,
228
- string(state), lastErr, time.Now().UTC(), finished, terminal, terminal, terminal, string(id))
229
- return err
268
+ string(state), lastErr, now, finished, terminal, terminal, terminal, string(id))
269
+ if err != nil {
270
+ return err
271
+ }
272
+ // Keep the derived timeline useful during retries and cancellation without
273
+ // making it an execution authority. Identify one active row first; a
274
+ // repeated terminal update with no active row is a no-op for the timeline.
275
+ stepStatus := stepStatusForRunState(state)
276
+ if stepStatus == "" {
277
+ return nil
278
+ }
279
+ var sequence int64
280
+ var startedAt sql.NullTime
281
+ lookupErr := p.DB.QueryRowContext(ctx, `
282
+ SELECT sequence, started_at FROM relay_run_steps
283
+ WHERE run_id = ? AND status IN ('running', 'waiting', 'blocked')
284
+ ORDER BY sequence DESC LIMIT 1`, string(id)).Scan(&sequence, &startedAt)
285
+ if errors.Is(lookupErr, sql.ErrNoRows) {
286
+ return nil
287
+ }
288
+ if lookupErr != nil {
289
+ // relay_run_steps is display/cache data. The authoritative relay_runs
290
+ // state update above has already succeeded and must not be retried just
291
+ // because this optional projection is unavailable.
292
+ slog.Warn("step projection state lookup unavailable", "runID", string(id), "state", state, "error", lookupErr)
293
+ return nil
294
+ }
295
+ var stepFinished any
296
+ if terminal {
297
+ if finished != nil {
298
+ stepFinished = *finished
299
+ } else {
300
+ stepFinished = now
301
+ }
302
+ }
303
+ result, err := p.DB.ExecContext(ctx, `
304
+ UPDATE relay_run_steps SET status = ?, message = CASE WHEN ? <> '' THEN ? ELSE message END,
305
+ finished_at = CASE WHEN ? THEN COALESCE(finished_at, ?) ELSE finished_at END
306
+ WHERE run_id = ? AND sequence = ? AND status IN ('running', 'waiting', 'blocked')`,
307
+ stepStatus, lastErr, lastErr, terminal, stepFinished,
308
+ string(id), sequence)
309
+ if err != nil {
310
+ slog.Warn("step projection state update unavailable", "runID", string(id), "state", state, "error", err)
311
+ return nil
312
+ }
313
+ updated, err := result.RowsAffected()
314
+ if err != nil || updated != 1 {
315
+ return nil
316
+ }
317
+ if terminal && startedAt.Valid {
318
+ endAt := now
319
+ if finished != nil {
320
+ endAt = *finished
321
+ }
322
+ duration := endAt.Sub(startedAt.Time)
323
+ if duration < 0 {
324
+ duration = 0
325
+ }
326
+ if _, durationErr := p.DB.ExecContext(ctx, `UPDATE relay_run_steps SET duration_ns = ? WHERE run_id = ? AND sequence = ?`, int64(duration), string(id), sequence); durationErr != nil {
327
+ slog.Warn("step projection duration update unavailable", "runID", string(id), "state", state, "error", durationErr)
328
+ }
329
+ }
330
+ return nil
230
331
  }
231
332
 
232
333
  func (p *RunProjection) updateRetry(ctx context.Context, id run.ID, status *run.RetryStatus) error {
@@ -271,6 +372,169 @@ func (p *RunProjection) updateNode(ctx context.Context, id run.ID, state run.Sta
271
372
  return tx.Commit()
272
373
  }
273
374
 
375
+ func stepStatusForRunState(state run.State) string {
376
+ switch state {
377
+ case run.StateRunning:
378
+ return string(run.StepRunning)
379
+ case run.StateWaiting:
380
+ return string(run.StepWaiting)
381
+ case run.StateBlocked:
382
+ return string(run.StepBlocked)
383
+ case run.StateCompleted:
384
+ return string(run.StepSucceeded)
385
+ case run.StateCanceled:
386
+ return string(run.StepCanceled)
387
+ default:
388
+ return ""
389
+ }
390
+ }
391
+
392
+ // upsertStep records one display row idempotently. The durable executor calls
393
+ // this at existing lifecycle boundaries; no route or report decision reads
394
+ // this table.
395
+ func (p *RunProjection) upsertStep(ctx context.Context, step run.StepEntry) error {
396
+ if step.RunID == "" {
397
+ return fmt.Errorf("step projection requires a run id")
398
+ }
399
+ if step.Node == "" {
400
+ return fmt.Errorf("step projection requires a node")
401
+ }
402
+ if step.Status == "" {
403
+ return fmt.Errorf("step projection requires a status")
404
+ }
405
+ var startedAt, finishedAt any
406
+ if step.StartedAt != nil && !step.StartedAt.IsZero() {
407
+ startedAt = step.StartedAt.UTC()
408
+ }
409
+ if step.FinishedAt != nil && !step.FinishedAt.IsZero() {
410
+ finishedAt = step.FinishedAt.UTC()
411
+ }
412
+ _, err := p.DB.ExecContext(ctx, `
413
+ INSERT INTO relay_run_steps
414
+ (run_id, sequence, node, node_visit_id, node_type, parent_sequence, depth, status, started_at, finished_at,
415
+ duration_ns, message, selected_route, runtime, resource)
416
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
417
+ ON CONFLICT(run_id, sequence) DO UPDATE SET
418
+ node = excluded.node,
419
+ node_visit_id = COALESCE(NULLIF(excluded.node_visit_id, ''), relay_run_steps.node_visit_id),
420
+ node_type = COALESCE(NULLIF(excluded.node_type, ''), relay_run_steps.node_type),
421
+ parent_sequence = CASE WHEN excluded.parent_sequence <> 0 THEN excluded.parent_sequence ELSE relay_run_steps.parent_sequence END,
422
+ depth = CASE WHEN excluded.depth <> 0 THEN excluded.depth ELSE relay_run_steps.depth END,
423
+ status = CASE
424
+ WHEN relay_run_steps.status IN ('succeeded', 'failed', 'canceled') THEN relay_run_steps.status
425
+ WHEN relay_run_steps.status = 'blocked' AND excluded.status IN ('pending', 'running', 'waiting') THEN relay_run_steps.status
426
+ WHEN relay_run_steps.status = 'waiting' AND excluded.status IN ('pending', 'running') THEN relay_run_steps.status
427
+ WHEN relay_run_steps.status = 'running' AND excluded.status = 'pending' THEN relay_run_steps.status
428
+ ELSE excluded.status
429
+ END,
430
+ started_at = COALESCE(relay_run_steps.started_at, excluded.started_at),
431
+ finished_at = CASE
432
+ WHEN relay_run_steps.status IN ('succeeded', 'failed', 'canceled') THEN relay_run_steps.finished_at
433
+ ELSE COALESCE(excluded.finished_at, relay_run_steps.finished_at)
434
+ END,
435
+ duration_ns = CASE
436
+ WHEN relay_run_steps.status IN ('succeeded', 'failed', 'canceled') THEN relay_run_steps.duration_ns
437
+ WHEN excluded.duration_ns <> 0 THEN excluded.duration_ns
438
+ WHEN excluded.finished_at IS NOT NULL AND COALESCE(relay_run_steps.started_at, excluded.started_at) IS NOT NULL
439
+ THEN CAST((julianday(excluded.finished_at) - julianday(COALESCE(relay_run_steps.started_at, excluded.started_at))) * 86400000000000 AS INTEGER)
440
+ ELSE relay_run_steps.duration_ns
441
+ END,
442
+ message = CASE WHEN relay_run_steps.status IN ('succeeded', 'failed', 'canceled') THEN relay_run_steps.message ELSE COALESCE(NULLIF(excluded.message, ''), relay_run_steps.message) END,
443
+ selected_route = CASE WHEN relay_run_steps.status IN ('succeeded', 'failed', 'canceled') THEN relay_run_steps.selected_route ELSE COALESCE(NULLIF(excluded.selected_route, ''), relay_run_steps.selected_route) END,
444
+ runtime = COALESCE(NULLIF(excluded.runtime, ''), relay_run_steps.runtime),
445
+ resource = COALESCE(NULLIF(excluded.resource, ''), relay_run_steps.resource)`,
446
+ string(step.RunID), step.Sequence, step.Node, nullableString(string(step.NodeVisitID)),
447
+ nullableString(step.NodeType), step.ParentSequence, step.Depth, string(step.Status), startedAt, finishedAt,
448
+ durationNanos(step), nullableString(step.Message), nullableString(step.Route),
449
+ nullableString(step.Runtime), nullableString(step.Resource))
450
+ return err
451
+ }
452
+
453
+ func durationNanos(step run.StepEntry) any {
454
+ if step.Duration != 0 {
455
+ return int64(step.Duration)
456
+ }
457
+ if step.StartedAt != nil && step.FinishedAt != nil {
458
+ duration := step.FinishedAt.Sub(*step.StartedAt)
459
+ if duration < 0 {
460
+ duration = 0
461
+ }
462
+ return int64(duration)
463
+ }
464
+ if step.DurationKnown {
465
+ return int64(0)
466
+ }
467
+ return nil
468
+ }
469
+
470
+ func (p *RunProjection) listSteps(ctx context.Context, id run.ID) ([]run.StepEntry, error) {
471
+ rows, err := p.DB.QueryContext(ctx, `
472
+ SELECT run_id, sequence, node, node_visit_id, node_type, parent_sequence, depth, status,
473
+ started_at, finished_at, duration_ns, message, selected_route, runtime, resource
474
+ FROM relay_run_steps WHERE run_id = ? ORDER BY sequence`, string(id))
475
+ if err != nil {
476
+ return nil, err
477
+ }
478
+ defer rows.Close()
479
+ steps := make([]run.StepEntry, 0)
480
+ for rows.Next() {
481
+ var step run.StepEntry
482
+ var visit, nodeType, message, route, runtime, resource sql.NullString
483
+ var started, finished sql.NullTime
484
+ var duration, parentSequence, depth sql.NullInt64
485
+ if err := rows.Scan(&step.RunID, &step.Sequence, &step.Node, &visit, &nodeType,
486
+ &parentSequence, &depth, &step.Status, &started, &finished, &duration,
487
+ &message, &route, &runtime, &resource); err != nil {
488
+ return nil, err
489
+ }
490
+ step.NodeVisitID = run.NodeVisitID(visit.String)
491
+ if parentSequence.Valid {
492
+ step.ParentSequence = parentSequence.Int64
493
+ }
494
+ if depth.Valid {
495
+ step.Depth = int(depth.Int64)
496
+ }
497
+ step.NodeType, step.Message, step.Route = nodeType.String, message.String, route.String
498
+ step.Runtime, step.Resource = runtime.String, resource.String
499
+ if started.Valid {
500
+ t := started.Time.UTC()
501
+ step.StartedAt = &t
502
+ }
503
+ if finished.Valid {
504
+ t := finished.Time.UTC()
505
+ step.FinishedAt = &t
506
+ }
507
+ if duration.Valid {
508
+ step.Duration = time.Duration(duration.Int64)
509
+ step.DurationKnown = true
510
+ }
511
+ steps = append(steps, step)
512
+ }
513
+ return steps, rows.Err()
514
+ }
515
+
516
+ func (p *RunProjection) getDetail(ctx context.Context, id run.ID) (run.RunDetail, error) {
517
+ base, err := p.get(ctx, id)
518
+ if err != nil {
519
+ return run.RunDetail{}, err
520
+ }
521
+ detail := run.NewRunDetail(base)
522
+ var created sql.NullTime
523
+ if err := p.DB.QueryRowContext(ctx, `SELECT created_at FROM relay_runs WHERE id = ?`, string(id)).Scan(&created); err != nil {
524
+ return run.RunDetail{}, err
525
+ }
526
+ if created.Valid {
527
+ createdAt := created.Time.UTC()
528
+ detail.CreatedAt = &createdAt
529
+ }
530
+ detail.Steps, err = p.listSteps(ctx, id)
531
+ if err != nil {
532
+ return run.RunDetail{}, err
533
+ }
534
+ detail.DeriveInspectionFields(time.Now().UTC())
535
+ return detail, nil
536
+ }
537
+
274
538
  func (p *RunProjection) getNodeRuntime(ctx context.Context, id run.ID, node string) (NodeRuntime, error) {
275
539
  var rt NodeRuntime
276
540
  var terminalID, sessionID sql.NullString
@@ -610,6 +874,10 @@ func (p *RunProjection) sweepRetention(ctx context.Context, olderThan time.Time)
610
874
  tx.Rollback()
611
875
  return ids, err
612
876
  }
877
+ if _, err := tx.ExecContext(ctx, `DELETE FROM relay_run_steps WHERE run_id = ?`, id); err != nil {
878
+ tx.Rollback()
879
+ return ids, err
880
+ }
613
881
  if _, err := tx.ExecContext(ctx, `DELETE FROM relay_runs WHERE id = ?`, id); err != nil {
614
882
  tx.Rollback()
615
883
  return ids, err
@@ -649,6 +917,22 @@ func (p *RunProjection) UpdateNode(ctx context.Context, id run.ID, state run.Sta
649
917
  return p.updateNode(ctx, id, state, node, visit)
650
918
  }
651
919
 
920
+ // UpsertStep records or updates one display-only execution step.
921
+ func (p *RunProjection) UpsertStep(ctx context.Context, step run.StepEntry) error {
922
+ return p.upsertStep(ctx, step)
923
+ }
924
+
925
+ // ListSteps returns the ordered display-only execution timeline for a run.
926
+ func (p *RunProjection) ListSteps(ctx context.Context, id run.ID) ([]run.StepEntry, error) {
927
+ return p.listSteps(ctx, id)
928
+ }
929
+
930
+ // GetDetail returns a run and its derived inspection data in one query
931
+ // boundary. The result never drives durable execution.
932
+ func (p *RunProjection) GetDetail(ctx context.Context, id run.ID) (run.RunDetail, error) {
933
+ return p.getDetail(ctx, id)
934
+ }
935
+
652
936
  // GetNodeRuntime returns one node's persisted runtime binding.
653
937
  func (p *RunProjection) GetNodeRuntime(ctx context.Context, id run.ID, node string) (NodeRuntime, error) {
654
938
  return p.getNodeRuntime(ctx, id, node)
@@ -80,6 +80,7 @@ func TestSharedProjectionSchemaAndQueries(t *testing.T) {
80
80
  "relay_node_runtime",
81
81
  "relay_node_sessions",
82
82
  "relay_processed_reports",
83
+ "relay_run_steps",
83
84
  "relay_runs",
84
85
  }
85
86
  if len(tables) != len(wantTables) {
@@ -209,8 +210,8 @@ func TestProjectionImplementationIsSharedByExecutorModes(t *testing.T) {
209
210
  if err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name LIKE 'relay_%'`).Scan(&count); err != nil {
210
211
  t.Fatal(err)
211
212
  }
212
- if count != 5 {
213
- t.Fatalf("relay schema table count = %d, want five shared tables", count)
213
+ if count != 6 {
214
+ t.Fatalf("relay schema table count = %d, want six shared tables", count)
214
215
  }
215
216
  if err := p.InsertStart(context.Background(), projectionStart(run.ID("run-"+mode), "PAY-"+mode), time.Now().UTC()); err != nil {
216
217
  t.Fatalf("insert %s run through shared projection: %v", mode, err)
@@ -411,6 +411,15 @@ func (a *Activities) CompleteMailbox(ctx context.Context, w run.Work, mailbox ta
411
411
 
412
412
  // Projection activities: idempotent read-model updates.
413
413
 
414
+ func (a *Activities) ProjectionUpsertStep(ctx context.Context, step run.StepEntry) error {
415
+ if err := a.Runs.UpsertStep(ctx, step); err != nil {
416
+ // Step detail is a cache. Never let a display-projection failure block
417
+ // route selection, report acceptance, or durable graph progression.
418
+ slog.Warn("step projection unavailable", "runID", string(step.RunID), "node", step.Node, "sequence", step.Sequence, "error", err)
419
+ }
420
+ return nil
421
+ }
422
+
414
423
  func (a *Activities) ProjectionUpdateNode(ctx context.Context, id run.ID, state run.State, node string, visit run.NodeVisitID) error {
415
424
  return a.Runs.UpdateNode(ctx, id, state, node, visit)
416
425
  }
@@ -362,6 +362,19 @@ func (e *Engine) GetRun(ctx context.Context, id run.ID) (run.Run, error) {
362
362
  func (e *Engine) FindRunByTicket(ctx context.Context, ticket string) (run.Run, error) {
363
363
  return e.runs.FindByTicket(ctx, ticket)
364
364
  }
365
+ func (e *Engine) GetRunDetail(ctx context.Context, id run.ID) (run.RunDetail, error) {
366
+ detail, err := e.runs.GetDetail(ctx, id)
367
+ if err != nil {
368
+ return run.RunDetail{}, err
369
+ }
370
+ // Static definitions only contribute explicit pending display rows. The
371
+ // derived projection is never consulted by Temporal workflow code.
372
+ if wf, wfErr := e.workflowFromHistory(ctx, id, ""); wfErr == nil {
373
+ detail.AddPendingNodes(*wf)
374
+ detail.DeriveInspectionFields(time.Now().UTC())
375
+ }
376
+ return detail, nil
377
+ }
365
378
  func (e *Engine) ListRuns(ctx context.Context, filter run.Filter) ([]run.Run, error) {
366
379
  return e.runs.List(ctx, filter)
367
380
  }