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.
- package/README.md +19 -5
- package/cmd/relay-flow/main.go +224 -23
- package/cmd/relay-flow/observability_test.go +204 -0
- package/cmd/relay-flow/render.go +629 -0
- package/cmd/relay-flow/serve.go +32 -1
- package/internal/execution/goworkflows/activities.go +9 -0
- package/internal/execution/goworkflows/engine.go +15 -0
- package/internal/execution/goworkflows/interpreter.go +86 -3
- package/internal/execution/goworkflows/projection.go +16 -0
- package/internal/execution/projection/detail_test.go +259 -0
- package/internal/execution/projection/projection.go +290 -6
- package/internal/execution/projection/projection_test.go +3 -2
- package/internal/execution/temporal/activities.go +9 -0
- package/internal/execution/temporal/engine.go +13 -0
- package/internal/execution/temporal/interpreter.go +131 -3
- package/internal/execution/temporal/operations.go +174 -0
- package/internal/execution/temporal/operations_test.go +36 -0
- package/internal/harness/opencode/opencode_test.go +1 -1
- package/internal/harness/opencode/repo_setup.go +1 -1
- package/internal/run/detail.go +277 -0
- package/internal/run/detail_test.go +71 -0
- package/internal/run/run.go +4 -0
- package/internal/server/client.go +39 -0
- package/internal/server/observability.go +109 -0
- package/internal/server/observability_test.go +60 -0
- package/internal/server/server.go +36 -0
- package/package.json +1 -1
|
@@ -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 =
|
|
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,
|
|
229
|
-
|
|
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 !=
|
|
213
|
-
t.Fatalf("relay schema table count = %d, want
|
|
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
|
-
|
|
107
|
+
steps := append([]run.StepEntry(nil), s.steps...)
|
|
108
|
+
return RunStateSnapshot{Run: s.run, RuntimeBindings: bindings, Steps: steps}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
func (s *workflowState) upsertStep(step run.StepEntry) {
|
|
112
|
+
for i := range s.steps {
|
|
113
|
+
if s.steps[i].Sequence == step.Sequence {
|
|
114
|
+
previous := s.steps[i]
|
|
115
|
+
if step.StartedAt == nil {
|
|
116
|
+
step.StartedAt = previous.StartedAt
|
|
117
|
+
}
|
|
118
|
+
if step.FinishedAt == nil {
|
|
119
|
+
step.FinishedAt = previous.FinishedAt
|
|
120
|
+
}
|
|
121
|
+
if step.Duration == 0 {
|
|
122
|
+
step.Duration = previous.Duration
|
|
123
|
+
}
|
|
124
|
+
if step.Message == "" {
|
|
125
|
+
step.Message = previous.Message
|
|
126
|
+
}
|
|
127
|
+
if step.Route == "" {
|
|
128
|
+
step.Route = previous.Route
|
|
129
|
+
}
|
|
130
|
+
if step.Runtime == "" {
|
|
131
|
+
step.Runtime = previous.Runtime
|
|
132
|
+
}
|
|
133
|
+
s.steps[i] = step
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
s.steps = append(s.steps, step)
|
|
138
|
+
sort.Slice(s.steps, func(i, j int) bool { return s.steps[i].Sequence < s.steps[j].Sequence })
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
func (s *workflowState) updateLatestStep(status run.StepStatus, message string) {
|
|
142
|
+
for i := len(s.steps) - 1; i >= 0; i-- {
|
|
143
|
+
if s.steps[i].Status != run.StepRunning && s.steps[i].Status != run.StepWaiting && s.steps[i].Status != run.StepBlocked {
|
|
144
|
+
continue
|
|
145
|
+
}
|
|
146
|
+
step := s.steps[i]
|
|
147
|
+
step.Status = status
|
|
148
|
+
if message != "" {
|
|
149
|
+
step.Message = message
|
|
150
|
+
}
|
|
151
|
+
s.upsertStep(step)
|
|
152
|
+
return
|
|
153
|
+
}
|
|
105
154
|
}
|
|
106
155
|
|
|
107
156
|
var temporalActivityOptions = temporalworkflow.ActivityOptions{
|
|
@@ -254,6 +303,23 @@ func runGraph(ctx temporalworkflow.Context, start run.Start, state *workflowStat
|
|
|
254
303
|
return err
|
|
255
304
|
}
|
|
256
305
|
|
|
306
|
+
// The timeline is a derived display projection. Sequence is deterministic
|
|
307
|
+
// for Temporal replay and is never consulted for routing.
|
|
308
|
+
stepSequence := int64(0)
|
|
309
|
+
target, err := wf.StartTarget()
|
|
310
|
+
if err != nil {
|
|
311
|
+
return err
|
|
312
|
+
}
|
|
313
|
+
startStarted := temporalworkflow.Now(ctx).UTC()
|
|
314
|
+
startStep := run.StepEntry{RunID: start.ID, Sequence: stepSequence, Node: "start", NodeType: "lifecycle",
|
|
315
|
+
Status: run.StepRunning, StartedAt: &startStarted, Depth: 1}
|
|
316
|
+
if _, err := retryActivity(ctx, state, work, "start", func() (struct{}, error) {
|
|
317
|
+
return executeActivity[struct{}](ctx, activityProjectionUpsertStep, startStep)
|
|
318
|
+
}); err != nil {
|
|
319
|
+
return err
|
|
320
|
+
}
|
|
321
|
+
state.upsertStep(startStep)
|
|
322
|
+
|
|
257
323
|
startNode := wf.Nodes["start"]
|
|
258
324
|
if _, err := retryActivity(ctx, state, work, "start", func() (struct{}, error) {
|
|
259
325
|
return executeActivity[struct{}](ctx, activityApplyTaskConfig, work, "start", (*task.Mailbox)(nil), mergeTaskConfig(wf.TaskConfig, startNode.TaskConfig))
|
|
@@ -265,12 +331,19 @@ func runGraph(ctx temporalworkflow.Context, start run.Start, state *workflowStat
|
|
|
265
331
|
}); err != nil {
|
|
266
332
|
return err
|
|
267
333
|
}
|
|
268
|
-
|
|
269
|
-
|
|
334
|
+
startFinished := temporalworkflow.Now(ctx).UTC()
|
|
335
|
+
startStep.Status, startStep.FinishedAt, startStep.Route = run.StepSucceeded, &startFinished, target
|
|
336
|
+
startStep.StartedAt = &startStarted
|
|
337
|
+
if _, err := retryActivity(ctx, state, work, "start", func() (struct{}, error) {
|
|
338
|
+
return executeActivity[struct{}](ctx, activityProjectionUpsertStep, startStep)
|
|
339
|
+
}); err != nil {
|
|
270
340
|
return err
|
|
271
341
|
}
|
|
342
|
+
state.upsertStep(startStep)
|
|
272
343
|
|
|
273
344
|
current := target
|
|
345
|
+
lastStepByNode := map[string]int64{}
|
|
346
|
+
lastDepthByNode := map[string]int{}
|
|
274
347
|
for current != "end" {
|
|
275
348
|
node := wf.Nodes[current]
|
|
276
349
|
var visitID identity.NodeVisitID
|
|
@@ -280,6 +353,26 @@ func runGraph(ctx temporalworkflow.Context, start run.Start, state *workflowStat
|
|
|
280
353
|
return err
|
|
281
354
|
}
|
|
282
355
|
visit := run.NodeVisitID(visitID)
|
|
356
|
+
stepSequence++
|
|
357
|
+
stepParent := int64(0)
|
|
358
|
+
stepDepth := 1
|
|
359
|
+
if previous, ok := lastStepByNode[current]; ok {
|
|
360
|
+
stepParent = previous
|
|
361
|
+
stepDepth = lastDepthByNode[current] + 1
|
|
362
|
+
}
|
|
363
|
+
stepStarted := temporalworkflow.Now(ctx).UTC()
|
|
364
|
+
step := run.StepEntry{RunID: start.ID, Sequence: stepSequence, Node: current,
|
|
365
|
+
NodeVisitID: visit, NodeType: string(node.Type), Status: run.StepRunning,
|
|
366
|
+
StartedAt: &stepStarted, ParentSequence: stepParent, Depth: stepDepth, Runtime: node.Agent}
|
|
367
|
+
if _, err := retryActivity(ctx, state, work, current, func() (struct{}, error) {
|
|
368
|
+
return executeActivity[struct{}](ctx, activityProjectionUpsertStep, step)
|
|
369
|
+
}); err != nil {
|
|
370
|
+
return err
|
|
371
|
+
}
|
|
372
|
+
state.upsertStep(step)
|
|
373
|
+
lastStepByNode[current] = stepSequence
|
|
374
|
+
lastDepthByNode[current] = stepDepth
|
|
375
|
+
|
|
283
376
|
runtime, err := retryActivity(ctx, state, work, current, func() (NodeRuntime, error) {
|
|
284
377
|
return executeActivity[NodeRuntime](ctx, activityLoadNodeRuntime, start.ID, current)
|
|
285
378
|
})
|
|
@@ -347,6 +440,7 @@ func runGraph(ctx temporalworkflow.Context, start run.Start, state *workflowStat
|
|
|
347
440
|
}
|
|
348
441
|
state.run.State = run.StateWaiting
|
|
349
442
|
state.run.UpdatedAt = temporalworkflow.Now(ctx)
|
|
443
|
+
state.updateLatestStep(run.StepWaiting, "waiting")
|
|
350
444
|
|
|
351
445
|
reportCh := temporalworkflow.GetSignalChannel(ctx, reportSignalName)
|
|
352
446
|
reconcileCh := temporalworkflow.GetSignalChannel(ctx, reconcileSignalName)
|
|
@@ -417,6 +511,20 @@ func runGraph(ctx temporalworkflow.Context, start run.Start, state *workflowStat
|
|
|
417
511
|
}); err != nil {
|
|
418
512
|
return err
|
|
419
513
|
}
|
|
514
|
+
stepStatus := run.StepSucceeded
|
|
515
|
+
stepMessage := accepted.Report.Summary.Completed
|
|
516
|
+
if accepted.Report.Status == domainworkflow.OutcomeFailure {
|
|
517
|
+
stepStatus = run.StepFailed
|
|
518
|
+
stepMessage = accepted.Report.Summary.IssuesDiscovered
|
|
519
|
+
}
|
|
520
|
+
stepFinished := temporalworkflow.Now(ctx).UTC()
|
|
521
|
+
step.Status, step.FinishedAt, step.Message, step.Route = stepStatus, &stepFinished, stepMessage, accepted.Report.NextStep
|
|
522
|
+
if _, err := retryActivity(ctx, state, work, current, func() (struct{}, error) {
|
|
523
|
+
return executeActivity[struct{}](ctx, activityProjectionUpsertStep, step)
|
|
524
|
+
}); err != nil {
|
|
525
|
+
return err
|
|
526
|
+
}
|
|
527
|
+
state.upsertStep(step)
|
|
420
528
|
|
|
421
529
|
if _, err := retryActivity(ctx, state, work, current, func() (struct{}, error) {
|
|
422
530
|
return executeActivity[struct{}](ctx, activityComment, start.Repo, run.CommentWork{
|
|
@@ -463,6 +571,17 @@ func runGraph(ctx temporalworkflow.Context, start run.Start, state *workflowStat
|
|
|
463
571
|
current = next
|
|
464
572
|
}
|
|
465
573
|
|
|
574
|
+
stepSequence++
|
|
575
|
+
endStarted := temporalworkflow.Now(ctx).UTC()
|
|
576
|
+
endStep := run.StepEntry{RunID: start.ID, Sequence: stepSequence, Node: "end", NodeType: "lifecycle",
|
|
577
|
+
Status: run.StepRunning, StartedAt: &endStarted, Depth: 1}
|
|
578
|
+
if _, err := retryActivity(ctx, state, work, "end", func() (struct{}, error) {
|
|
579
|
+
return executeActivity[struct{}](ctx, activityProjectionUpsertStep, endStep)
|
|
580
|
+
}); err != nil {
|
|
581
|
+
return err
|
|
582
|
+
}
|
|
583
|
+
state.upsertStep(endStep)
|
|
584
|
+
|
|
466
585
|
endNode := wf.Nodes["end"]
|
|
467
586
|
if _, err := retryActivity(ctx, state, work, "end", func() (struct{}, error) {
|
|
468
587
|
return executeActivity[struct{}](ctx, activityApplyTaskConfig, work, "end", (*task.Mailbox)(nil), mergeTaskConfig(wf.TaskConfig, endNode.TaskConfig))
|
|
@@ -494,6 +613,13 @@ func runGraph(ctx temporalworkflow.Context, start run.Start, state *workflowStat
|
|
|
494
613
|
}
|
|
495
614
|
}
|
|
496
615
|
now := temporalworkflow.Now(ctx).UTC()
|
|
616
|
+
endStep.Status, endStep.FinishedAt = run.StepSucceeded, &now
|
|
617
|
+
if _, err := retryActivity(ctx, state, work, "end", func() (struct{}, error) {
|
|
618
|
+
return executeActivity[struct{}](ctx, activityProjectionUpsertStep, endStep)
|
|
619
|
+
}); err != nil {
|
|
620
|
+
return err
|
|
621
|
+
}
|
|
622
|
+
state.upsertStep(endStep)
|
|
497
623
|
if _, err := retryActivity(ctx, state, work, "", func() (struct{}, error) {
|
|
498
624
|
return executeActivity[struct{}](ctx, activityProjectionUpdateState, start.ID, run.StateCompleted, "", &now)
|
|
499
625
|
}); err != nil {
|
|
@@ -566,6 +692,7 @@ func retryActivity[T any](ctx temporalworkflow.Context, state *workflowState, wo
|
|
|
566
692
|
return zero, stateErr
|
|
567
693
|
}
|
|
568
694
|
state.run.State = run.StateWaiting
|
|
695
|
+
state.updateLatestStep(run.StepWaiting, "waiting")
|
|
569
696
|
}
|
|
570
697
|
return result, nil
|
|
571
698
|
}
|
|
@@ -580,6 +707,7 @@ func retryActivity[T any](ctx temporalworkflow.Context, state *workflowState, wo
|
|
|
580
707
|
failure.Message = blockedMessage(work, node, failure.Message)
|
|
581
708
|
blocked = true
|
|
582
709
|
state.run.State = run.StateBlocked
|
|
710
|
+
state.updateLatestStep(run.StepBlocked, failure.Message)
|
|
583
711
|
if _, stateErr := retryProjectionActivity(ctx, state, work, node, func() (struct{}, error) {
|
|
584
712
|
return executeActivity[struct{}](ctx, activityProjectionUpdateState, work.RunID, run.StateBlocked, failure.Message, (*time.Time)(nil))
|
|
585
713
|
}); stateErr != nil {
|