relay-flow 0.2.9-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.
@@ -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
  }
@@ -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 {