internal/relui: record task errors

This change introduces recording of task failures to the database. It
also improves UX of failures in the UI.

Updates golang/go#53207

Change-Id: Ic00c9a94228d3fc61c5b2e6da6f0b25d1c7d19dc
Reviewed-on: https://go-review.googlesource.com/c/build/+/410236
Reviewed-by: Dmitri Shuralyov <dmitshur@google.com>
TryBot-Result: Gopher Robot <gobot@golang.org>
Run-TryBot: Alex Rakoczy <alex@golang.org>
Reviewed-by: Heschi Kreinick <heschi@google.com>
Reviewed-by: Dmitri Shuralyov <dmitshur@golang.org>
diff --git a/internal/relui/db/db.go b/internal/relui/db/db.go
index ba9b109..23b9bf7 100644
--- a/internal/relui/db/db.go
+++ b/internal/relui/db/db.go
@@ -1,4 +1,6 @@
 // Code generated by sqlc. DO NOT EDIT.
+// versions:
+//   sqlc v1.13.0
 
 package db
 
diff --git a/internal/relui/db/models.go b/internal/relui/db/models.go
index d64109b..c376a03 100644
--- a/internal/relui/db/models.go
+++ b/internal/relui/db/models.go
@@ -1,4 +1,6 @@
 // Code generated by sqlc. DO NOT EDIT.
+// versions:
+//   sqlc v1.13.0
 
 package db
 
diff --git a/internal/relui/db/workflows.sql.go b/internal/relui/db/workflows.sql.go
index 2d6622b..98ee991 100644
--- a/internal/relui/db/workflows.sql.go
+++ b/internal/relui/db/workflows.sql.go
@@ -1,4 +1,6 @@
 // Code generated by sqlc. DO NOT EDIT.
+// versions:
+//   sqlc v1.13.0
 // source: workflows.sql
 
 package db
@@ -297,6 +299,7 @@
         name        = excluded.name,
         finished    = excluded.finished,
         result      = excluded.result,
+        error       = excluded.error,
         updated_at  = excluded.updated_at
 RETURNING workflow_id, name, finished, result, error, created_at, updated_at
 `
diff --git a/internal/relui/listener.go b/internal/relui/listener.go
index 8d819dc..d57ddd3 100644
--- a/internal/relui/listener.go
+++ b/internal/relui/listener.go
@@ -47,7 +47,7 @@
 			Name:       taskName,
 			Finished:   state.Finished,
 			Result:     sql.NullString{String: string(result), Valid: len(result) > 0},
-			Error:      sql.NullString{},
+			Error:      sql.NullString{String: state.Error, Valid: state.Error != ""},
 			CreatedAt:  updated,
 			UpdatedAt:  updated,
 		})
diff --git a/internal/relui/listener_test.go b/internal/relui/listener_test.go
index cc1fa93..b014e5a 100644
--- a/internal/relui/listener_test.go
+++ b/internal/relui/listener_test.go
@@ -22,39 +22,74 @@
 	defer cancel()
 	dbp := testDB(ctx, t)
 	q := db.New(dbp)
-	wfp := db.CreateWorkflowParams{ID: uuid.New()}
-	wf, err := q.CreateWorkflow(ctx, wfp)
-	if err != nil {
-		t.Fatalf("q.CreateWorkflow(%v, %v) = %v, wanted no error", ctx, wfp, err)
-	}
 
-	l := &PGListener{db: dbp}
-	state := &workflow.TaskState{
-		Name:             "TestTask",
-		Finished:         true,
-		Result:           struct{ Value int }{5},
-		SerializedResult: []byte(`{"Value": 5}`),
-		Error:            "",
+	cases := []struct {
+		desc  string
+		state *workflow.TaskState
+		want  []db.Task
+	}{
+		{
+			desc: "records successful tasks",
+			state: &workflow.TaskState{
+				Name:             "TestTask",
+				Finished:         true,
+				Result:           struct{ Value int }{5},
+				SerializedResult: []byte(`{"Value": 5}`),
+				Error:            "",
+			},
+			want: []db.Task{
+				{
+					Name:      "TestTask",
+					Finished:  true,
+					Result:    sql.NullString{String: `{"Value": 5}`, Valid: true},
+					CreatedAt: time.Now(), // cmpopts.EquateApproxTime
+					UpdatedAt: time.Now(), // cmpopts.EquateApproxTime
+				},
+			},
+		},
+		{
+			desc: "records failing tasks",
+			state: &workflow.TaskState{
+				Name:             "TestTask",
+				Finished:         true,
+				Result:           struct{ Value int }{5},
+				SerializedResult: []byte(`{"Value": 5}`),
+				Error:            "it's completely broken and hopeless",
+			},
+			want: []db.Task{
+				{
+					Name:      "TestTask",
+					Finished:  true,
+					Result:    sql.NullString{String: `{"Value": 5}`, Valid: true},
+					Error:     sql.NullString{String: "it's completely broken and hopeless", Valid: true},
+					CreatedAt: time.Now(), // cmpopts.EquateApproxTime
+					UpdatedAt: time.Now(), // cmpopts.EquateApproxTime
+				},
+			},
+		},
 	}
-	err = l.TaskStateChanged(wf.ID, "TestTask", state)
-	if err != nil {
-		t.Fatalf("l.TaskStateChanged(%v, %q, %v) = %v, wanted no error", wf.ID, "TestTask", state, err)
-	}
+	for _, c := range cases {
+		t.Run(c.desc, func(t *testing.T) {
+			wfp := db.CreateWorkflowParams{ID: uuid.New()}
+			wf, err := q.CreateWorkflow(ctx, wfp)
+			if err != nil {
+				t.Fatalf("q.CreateWorkflow(%v, %v) = %v, wanted no error", ctx, wfp, err)
+			}
 
-	tasks, err := q.TasksForWorkflow(ctx, wf.ID)
-	if err != nil {
-		t.Fatalf("q.TasksForWorkflow(%v, %v) = %v, %v, wanted no error", ctx, wf.ID, tasks, err)
-	}
-	want := []db.Task{{
-		WorkflowID: wf.ID,
-		Name:       "TestTask",
-		Finished:   true,
-		Result:     sql.NullString{String: `{"Value": 5}`, Valid: true},
-		CreatedAt:  time.Now(), // cmpopts.EquateApproxTime
-		UpdatedAt:  time.Now(), // cmpopts.EquateApproxTime
-	}}
-	if diff := cmp.Diff(want, tasks, cmpopts.EquateApproxTime(time.Minute)); diff != "" {
-		t.Errorf("q.TasksForWorkflow(_, %q) mismatch (-want +got):\n%s", wf.ID, diff)
+			l := &PGListener{db: dbp}
+			err = l.TaskStateChanged(wf.ID, "TestTask", c.state)
+			if err != nil {
+				t.Fatalf("l.TaskStateChanged(%v, %q, %v) = %v, wanted no error", wf.ID, "TestTask", c.state, err)
+			}
+
+			tasks, err := q.TasksForWorkflow(ctx, wf.ID)
+			if err != nil {
+				t.Fatalf("q.TasksForWorkflow(%v, %v) = %v, %v, wanted no error", ctx, wf.ID, tasks, err)
+			}
+			if diff := cmp.Diff(c.want, tasks, cmpopts.EquateApproxTime(time.Minute), cmpopts.IgnoreFields(db.Task{}, "WorkflowID")); diff != "" {
+				t.Errorf("q.TasksForWorkflow(_, %q) mismatch (-want +got):\n%s", wf.ID, diff)
+			}
+		})
 	}
 }
 
diff --git a/internal/relui/queries/workflows.sql b/internal/relui/queries/workflows.sql
index 91013ec..bb59c19 100644
--- a/internal/relui/queries/workflows.sql
+++ b/internal/relui/queries/workflows.sql
@@ -30,6 +30,7 @@
         name        = excluded.name,
         finished    = excluded.finished,
         result      = excluded.result,
+        error       = excluded.error,
         updated_at  = excluded.updated_at
 RETURNING *;
 
diff --git a/internal/relui/static/styles.css b/internal/relui/static/styles.css
index 9e28d75..7dce7f7 100644
--- a/internal/relui/static/styles.css
+++ b/internal/relui/static/styles.css
@@ -205,14 +205,12 @@
 }
 .TaskList-itemLogLine:nth-child(even) {
   background-color: #fafafa;
+  font-family: monospace;
 }
 .TaskList-itemLogLineError {
   background-color: #c9483c;
   color: white;
-}
-.TaskList-errorBody {
-  display: block;
-  white-space: pre-wrap;
+  padding: 0.5rem 1rem;
 }
 .TaskList-item {
 }
diff --git a/internal/relui/templates/home.html b/internal/relui/templates/home.html
index fecc52f..a7156aa 100644
--- a/internal/relui/templates/home.html
+++ b/internal/relui/templates/home.html
@@ -77,9 +77,9 @@
                 <tr class="TaskList-itemLogsRow">
                   <td class="TaskList-itemLogs" colspan="6">
                     {{if $task.Error.Valid}}
-                    <div class="TaskList-itemLogLine TaskList-itemLogLineError">
-                      <code class="TaskList-errorBody">{{$task.Error.Value}}</code>
-                    </div>
+                      <div class="TaskList-itemLogLine TaskList-itemLogLineError">
+                        {{- $task.Error.Value -}}
+                      </div>
                     {{end}}
                     {{range $log := $.Logs $workflow.ID  $task.Name}}
                       <div class="TaskList-itemLogLine">