internal/relui: persist logs to database

Save log output from workflow tasks in the database. Render logs in the
UI alongside each task. This will almost certainly be a performance
problem if we have many logs until we implement pagination.

For golang/go#47401

Change-Id: I4ddc3d7845e4559cc636b3b7972e0adefe9fcec4
Reviewed-on: https://go-review.googlesource.com/c/build/+/353170
Trust: Alexander Rakoczy <alex@golang.org>
Run-TryBot: Alexander Rakoczy <alex@golang.org>
TryBot-Result: Go Bot <gobot@golang.org>
Reviewed-by: Carlos Amedee <carlos@golang.org>
Reviewed-by: Heschi Kreinick <heschi@google.com>
diff --git a/cmd/relui/Makefile b/cmd/relui/Makefile
index 82e1b56..f7c75ae 100644
--- a/cmd/relui/Makefile
+++ b/cmd/relui/Makefile
@@ -33,7 +33,7 @@
 
 schema.sql: $(MIGRATION_FILES) $(GO_FILES)
 	docker build -f Dockerfile -t golang/relui:$(VERSION) ../..
-	docker run --rm --name=relui-dev-migrate -v $(POSTGRES_RUN_DEV) $(DOCKER_TAG) --only-migrate
+	docker run --rm --name=relui-dev-migrate -v $(POSTGRES_RUN_DEV) -e PGUSER=$(POSTGRES_USER) -e PGDATABASE=relui-dev $(DOCKER_TAG) --only-migrate
 	docker exec postgres-dev pg_dump --username=$(POSTGRES_USER) --schema-only relui-dev > schema.sql
 
 .PHONY: test
diff --git a/cmd/relui/schema.sql b/cmd/relui/schema.sql
index 7326ab2..e1394b0 100644
--- a/cmd/relui/schema.sql
+++ b/cmd/relui/schema.sql
@@ -33,6 +33,44 @@
 ALTER TABLE public.migrations OWNER TO postgres;
 
 --
+-- Name: task_logs; Type: TABLE; Schema: public; Owner: postgres
+--
+
+CREATE TABLE public.task_logs (
+    id integer NOT NULL,
+    workflow_id uuid NOT NULL,
+    task_name text NOT NULL,
+    body text NOT NULL,
+    created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
+    updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL
+);
+
+
+ALTER TABLE public.task_logs OWNER TO postgres;
+
+--
+-- Name: task_logs_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres
+--
+
+CREATE SEQUENCE public.task_logs_id_seq
+    AS integer
+    START WITH 1
+    INCREMENT BY 1
+    NO MINVALUE
+    NO MAXVALUE
+    CACHE 1;
+
+
+ALTER TABLE public.task_logs_id_seq OWNER TO postgres;
+
+--
+-- Name: task_logs_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres
+--
+
+ALTER SEQUENCE public.task_logs_id_seq OWNED BY public.task_logs.id;
+
+
+--
 -- Name: tasks; Type: TABLE; Schema: public; Owner: postgres
 --
 
@@ -65,6 +103,13 @@
 ALTER TABLE public.workflows OWNER TO postgres;
 
 --
+-- Name: task_logs id; Type: DEFAULT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.task_logs ALTER COLUMN id SET DEFAULT nextval('public.task_logs_id_seq'::regclass);
+
+
+--
 -- Name: migrations migrations_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
 --
 
@@ -73,6 +118,14 @@
 
 
 --
+-- Name: task_logs task_logs_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.task_logs
+    ADD CONSTRAINT task_logs_pkey PRIMARY KEY (id);
+
+
+--
 -- Name: tasks tasks_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
 --
 
@@ -89,6 +142,14 @@
 
 
 --
+-- Name: task_logs task_logs_workflow_id_task_name_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.task_logs
+    ADD CONSTRAINT task_logs_workflow_id_task_name_fkey FOREIGN KEY (workflow_id, task_name) REFERENCES public.tasks(workflow_id, name);
+
+
+--
 -- Name: tasks tasks_workflow_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres
 --
 
diff --git a/internal/relui/db/models.go b/internal/relui/db/models.go
index d5027b2..8342e7a 100644
--- a/internal/relui/db/models.go
+++ b/internal/relui/db/models.go
@@ -24,6 +24,15 @@
 	UpdatedAt  time.Time
 }
 
+type TaskLog struct {
+	ID         int32
+	WorkflowID uuid.UUID
+	TaskName   string
+	Body       string
+	CreatedAt  time.Time
+	UpdatedAt  time.Time
+}
+
 type Workflow struct {
 	ID        uuid.UUID
 	Params    sql.NullString
diff --git a/internal/relui/db/workflows.sql.go b/internal/relui/db/workflows.sql.go
index f665e34..cb219ed 100644
--- a/internal/relui/db/workflows.sql.go
+++ b/internal/relui/db/workflows.sql.go
@@ -50,6 +50,32 @@
 	return i, err
 }
 
+const createTaskLog = `-- name: CreateTaskLog :one
+INSERT INTO task_logs (workflow_id, task_name, body)
+VALUES ($1, $2, $3)
+RETURNING id, workflow_id, task_name, body, created_at, updated_at
+`
+
+type CreateTaskLogParams struct {
+	WorkflowID uuid.UUID
+	TaskName   string
+	Body       string
+}
+
+func (q *Queries) CreateTaskLog(ctx context.Context, arg CreateTaskLogParams) (TaskLog, error) {
+	row := q.db.QueryRow(ctx, createTaskLog, arg.WorkflowID, arg.TaskName, arg.Body)
+	var i TaskLog
+	err := row.Scan(
+		&i.ID,
+		&i.WorkflowID,
+		&i.TaskName,
+		&i.Body,
+		&i.CreatedAt,
+		&i.UpdatedAt,
+	)
+	return i, err
+}
+
 const createWorkflow = `-- name: CreateWorkflow :one
 INSERT INTO workflows (id, params, name, created_at, updated_at)
 VALUES ($1, $2, $3, $4, $5)
@@ -83,6 +109,78 @@
 	return i, err
 }
 
+const taskLogs = `-- name: TaskLogs :many
+SELECT task_logs.id, task_logs.workflow_id, task_logs.task_name, task_logs.body, task_logs.created_at, task_logs.updated_at
+FROM task_logs
+ORDER BY created_at
+`
+
+func (q *Queries) TaskLogs(ctx context.Context) ([]TaskLog, error) {
+	rows, err := q.db.Query(ctx, taskLogs)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	var items []TaskLog
+	for rows.Next() {
+		var i TaskLog
+		if err := rows.Scan(
+			&i.ID,
+			&i.WorkflowID,
+			&i.TaskName,
+			&i.Body,
+			&i.CreatedAt,
+			&i.UpdatedAt,
+		); err != nil {
+			return nil, err
+		}
+		items = append(items, i)
+	}
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+	return items, nil
+}
+
+const taskLogsForTask = `-- name: TaskLogsForTask :many
+SELECT task_logs.id, task_logs.workflow_id, task_logs.task_name, task_logs.body, task_logs.created_at, task_logs.updated_at
+FROM task_logs
+WHERE workflow_id=$1 AND task_name = $2
+ORDER BY created_at
+`
+
+type TaskLogsForTaskParams struct {
+	WorkflowID uuid.UUID
+	TaskName   string
+}
+
+func (q *Queries) TaskLogsForTask(ctx context.Context, arg TaskLogsForTaskParams) ([]TaskLog, error) {
+	rows, err := q.db.Query(ctx, taskLogsForTask, arg.WorkflowID, arg.TaskName)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	var items []TaskLog
+	for rows.Next() {
+		var i TaskLog
+		if err := rows.Scan(
+			&i.ID,
+			&i.WorkflowID,
+			&i.TaskName,
+			&i.Body,
+			&i.CreatedAt,
+			&i.UpdatedAt,
+		); err != nil {
+			return nil, err
+		}
+		items = append(items, i)
+	}
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+	return items, nil
+}
+
 const tasks = `-- name: Tasks :many
 SELECT tasks.workflow_id, tasks.name, tasks.finished, tasks.result, tasks.error, tasks.created_at, tasks.updated_at
 FROM tasks
@@ -121,7 +219,7 @@
 SELECT tasks.workflow_id, tasks.name, tasks.finished, tasks.result, tasks.error, tasks.created_at, tasks.updated_at
 FROM tasks
 WHERE workflow_id=$1
-ORDER BY created_At
+ORDER BY created_at
 `
 
 func (q *Queries) TasksForWorkflow(ctx context.Context, workflowID uuid.UUID) ([]Task, error) {
@@ -217,11 +315,15 @@
 }
 
 const workflows = `-- name: Workflows :many
+
 SELECT id, params, name, created_at, updated_at
 FROM workflows
 ORDER BY created_at DESC
 `
 
+// Copyright 2021 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
 func (q *Queries) Workflows(ctx context.Context) ([]Workflow, error) {
 	rows, err := q.db.Query(ctx, workflows)
 	if err != nil {
diff --git a/internal/relui/listener.go b/internal/relui/listener.go
new file mode 100644
index 0000000..8097ac4
--- /dev/null
+++ b/internal/relui/listener.go
@@ -0,0 +1,91 @@
+// Copyright 2021 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package relui
+
+import (
+	"context"
+	"database/sql"
+	"encoding/json"
+	"fmt"
+	"log"
+	"time"
+
+	"github.com/google/uuid"
+	"github.com/jackc/pgx/v4"
+	"github.com/jackc/pgx/v4/pgxpool"
+	"golang.org/x/build/internal/relui/db"
+	"golang.org/x/build/internal/workflow"
+)
+
+// listener implements workflow.Listener for recording workflow state.
+type listener struct {
+	db *pgxpool.Pool
+}
+
+// TaskStateChanged is called whenever a task is updated by the
+// workflow. The workflow.TaskState is persisted as a db.Task,
+// creating or updating a row as necessary.
+func (l *listener) TaskStateChanged(workflowID uuid.UUID, taskName string, state *workflow.TaskState) error {
+	log.Printf("TaskStateChanged(%q, %q, %v)", workflowID, taskName, state)
+	ctx, cancel := context.WithCancel(context.Background())
+	defer cancel()
+	result, err := json.Marshal(state.Result)
+	if err != nil {
+		return err
+	}
+	err = l.db.BeginFunc(ctx, func(tx pgx.Tx) error {
+		q := db.New(tx)
+		updated := time.Now()
+		_, err := q.UpsertTask(ctx, db.UpsertTaskParams{
+			WorkflowID: workflowID,
+			Name:       taskName,
+			Finished:   state.Finished,
+			Result:     sql.NullString{String: string(result), Valid: len(result) > 0},
+			Error:      sql.NullString{},
+			CreatedAt:  updated,
+			UpdatedAt:  updated,
+		})
+		return err
+	})
+	if err != nil {
+		log.Printf("TaskStateChanged(%q, %q, %v) = %v", workflowID, taskName, state, err)
+	}
+	return err
+}
+
+func (l *listener) Logger(workflowID uuid.UUID, taskName string) workflow.Logger {
+	return &postgresLogger{
+		db:         l.db,
+		workflowID: workflowID,
+		taskName:   taskName,
+	}
+}
+
+// postgresLogger logs task output to the database. It implements workflow.Logger.
+type postgresLogger struct {
+	db         *pgxpool.Pool
+	workflowID uuid.UUID
+	taskName   string
+}
+
+func (l *postgresLogger) Printf(format string, v ...interface{}) {
+	ctx := context.Background()
+	err := l.db.BeginFunc(ctx, func(tx pgx.Tx) error {
+		q := db.New(tx)
+		body := fmt.Sprintf(format, v...)
+		_, err := q.CreateTaskLog(ctx, db.CreateTaskLogParams{
+			WorkflowID: l.workflowID,
+			TaskName:   l.taskName,
+			Body:       body,
+		})
+		if err != nil {
+			log.Printf("q.CreateTaskLog(%v, %v, %q) = %v", l.workflowID, l.taskName, body, err)
+		}
+		return err
+	})
+	if err != nil {
+		log.Printf("l.Printf(%q, %v) = %v", format, v, err)
+	}
+}
diff --git a/internal/relui/listener_test.go b/internal/relui/listener_test.go
new file mode 100644
index 0000000..468ea34
--- /dev/null
+++ b/internal/relui/listener_test.go
@@ -0,0 +1,95 @@
+// Copyright 2021 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package relui
+
+import (
+	"context"
+	"database/sql"
+	"testing"
+	"time"
+
+	"github.com/google/go-cmp/cmp"
+	"github.com/google/go-cmp/cmp/cmpopts"
+	"github.com/google/uuid"
+	"golang.org/x/build/internal/relui/db"
+	"golang.org/x/build/internal/workflow"
+)
+
+func TestListenerTaskStateChanged(t *testing.T) {
+	ctx, cancel := context.WithCancel(context.Background())
+	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 := &listener{db: dbp}
+	state := &workflow.TaskState{
+		Name:             "TestTask",
+		Finished:         true,
+		Result:           struct{ Value int }{5},
+		SerializedResult: []byte(`{"Value": 5}`),
+		Error:            "",
+	}
+	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)
+	}
+
+	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)
+	}
+}
+
+func TestListenerLogger(t *testing.T) {
+	ctx, cancel := context.WithCancel(context.Background())
+	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)
+	}
+	params := db.UpsertTaskParams{WorkflowID: wf.ID, Name: "TestTask"}
+	_, err = q.UpsertTask(ctx, params)
+	if err != nil {
+		t.Fatalf("q.UpsertTask(%v, %v) = %v, wanted no error", ctx, params, err)
+	}
+
+	l := &listener{db: dbp}
+	l.Logger(wf.ID, "TestTask").Printf("A fancy log line says %q", "hello")
+
+	logs, err := q.TaskLogs(ctx)
+	if err != nil {
+		t.Fatalf("q.TaskLogs(%v) = %v, wanted no error", ctx, err)
+	}
+	want := []db.TaskLog{{
+		WorkflowID: wf.ID,
+		TaskName:   "TestTask",
+		Body:       `A fancy log line says "hello"`,
+		CreatedAt:  time.Now(), // cmpopts.EquateApproxTime
+		UpdatedAt:  time.Now(), // cmpopts.EquateApproxTime
+	}}
+	if diff := cmp.Diff(want, logs, cmpopts.EquateApproxTime(time.Minute), cmpopts.IgnoreFields(db.TaskLog{}, "ID")); diff != "" {
+		t.Errorf("q.TaskLogs(_, %q) mismatch (-want +got):\n%s", wf.ID, diff)
+	}
+}
diff --git a/internal/relui/migrations/20210909141405_create_workflows_table.up.sql b/internal/relui/migrations/20210909141405_create_workflows_table.up.sql
index 198f813..080468f 100644
--- a/internal/relui/migrations/20210909141405_create_workflows_table.up.sql
+++ b/internal/relui/migrations/20210909141405_create_workflows_table.up.sql
@@ -16,7 +16,7 @@
     finished bool NOT NULL DEFAULT false,
     result jsonb,
     error text,
-    created_at timestamp with time zone NOT NULl default current_timestamp,
+    created_at timestamp with time zone NOT NULL default current_timestamp,
     updated_at timestamp with time zone NOT NULL default current_timestamp,
     PRIMARY KEY (workflow_id, name)
 );
diff --git a/internal/relui/migrations/20210928195553_create_task_logs_table.down.sql b/internal/relui/migrations/20210928195553_create_task_logs_table.down.sql
new file mode 100644
index 0000000..f914f38
--- /dev/null
+++ b/internal/relui/migrations/20210928195553_create_task_logs_table.down.sql
@@ -0,0 +1,5 @@
+-- Copyright 2021 The Go Authors. All rights reserved.
+-- Use of this source code is governed by a BSD-style
+-- license that can be found in the LICENSE file.
+
+DROP TABLE task_logs;
diff --git a/internal/relui/migrations/20210928195553_create_task_logs_table.up.sql b/internal/relui/migrations/20210928195553_create_task_logs_table.up.sql
new file mode 100644
index 0000000..226bb00
--- /dev/null
+++ b/internal/relui/migrations/20210928195553_create_task_logs_table.up.sql
@@ -0,0 +1,13 @@
+-- Copyright 2021 The Go Authors. All rights reserved.
+-- Use of this source code is governed by a BSD-style
+-- license that can be found in the LICENSE file.
+
+CREATE TABLE task_logs (
+  id SERIAL PRIMARY KEY,
+  workflow_id uuid NOT NULL,
+  task_name text NOT NULL,
+  body text NOT NULL,
+  created_at timestamp with time zone NOT NULL default current_timestamp,
+  updated_at timestamp with time zone NOT NULL default current_timestamp,
+  FOREIGN KEY (workflow_id, task_name) REFERENCES tasks (workflow_id, name)
+);
diff --git a/internal/relui/queries/workflows.sql b/internal/relui/queries/workflows.sql
index fe58679..a197363 100644
--- a/internal/relui/queries/workflows.sql
+++ b/internal/relui/queries/workflows.sql
@@ -42,4 +42,20 @@
 SELECT tasks.*
 FROM tasks
 WHERE workflow_id=$1
-ORDER BY created_At;
+ORDER BY created_at;
+
+-- name: CreateTaskLog :one
+INSERT INTO task_logs (workflow_id, task_name, body)
+VALUES ($1, $2, $3)
+RETURNING *;
+
+-- name: TaskLogsForTask :many
+SELECT task_logs.*
+FROM task_logs
+WHERE workflow_id=$1 AND task_name = $2
+ORDER BY created_at;
+
+-- name: TaskLogs :many
+SELECT task_logs.*
+FROM task_logs
+ORDER BY created_at;
diff --git a/internal/relui/static/styles.css b/internal/relui/static/styles.css
index e92e8e2..0a2ba88 100644
--- a/internal/relui/static/styles.css
+++ b/internal/relui/static/styles.css
@@ -36,7 +36,6 @@
 }
 .Site-content {
   flex: 1 0 auto;
-  padding: 0.625rem;
   width: 100%;
 }
 .Site-header {
@@ -51,22 +50,17 @@
   font-size: 1.5rem;
   margin: 0;
 }
-@media only screen and (min-width: 75rem) {
-  .Workflows,
-  .NewWorkflow {
-    width: 74.75rem;
-  }
-}
-@media only screen and (min-width: 48rem) {
-  .Workflows,
-  .NewWorkflow {
-    margin: 0 auto;
-  }
+.Workflows {
+  padding: 0 0.625rem;
 }
 .Workflows-header {
   align-items: center;
+  background: #fff;
+  border-bottom: 0.0625rem solid #d6d6d6;
   display: flex;
   justify-content: space-between;
+  margin: 0 -0.625rem;
+  padding: 0 0.625rem;
 }
 .WorkflowList,
 .TaskList {
@@ -74,23 +68,49 @@
   margin: 0;
   padding: 0;
 }
+.WorkflowList-title {
+}
 .WorkflowList-sectionTitle {
-  margin-bottom: 0.5rem;
   font-weight: normal;
+  margin-bottom: 0.5rem;
+}
+.WorkflowList-item {
+  background: #fff;
+  margin-top: 1rem;
+  padding: 0 0.5rem;
+  border: 0.0625rem solid #d6d6d6;
+  border-radius: 0.0625rem;
 }
 .TaskList {
-  border: 1px solid #d6d6d6;
-  border-radius: 0.25rem;
-}
-.TaskList-item {
-  display: flex;
-  align-items: center;
-  padding: 0.5rem;
-  justify-content: space-between;
+  border-bottom: 0.0625rem solid #d6d6d6;
+  border-top: 0.0625rem solid #d6d6d6;
+  margin-bottom: 1rem;
 }
 .TaskList-item + .TaskList-item {
   border-top: 0.0625rem solid #d6d6d6;
 }
+.TaskList-itemSummary {
+  align-items: center;
+  cursor: pointer;
+  justify-content: space-between;
+  padding: 0.5rem;
+}
+.TaskList-itemSummary:hover {
+  background-color: #fafafa;
+}
+.TaskList-itemLogs {
+  background-color: #f5f5f5;
+  box-shadow: inset 0 6px 6px -8px #888;
+  font-size: 0.875rem;
+  margin: 0;
+  padding: 1rem 0;
+}
+.TaskList-itemLogLine {
+  padding: 0 1rem;
+}
+.TaskList-itemLogLine:nth-child(even) {
+  background-color: #fafafa;
+}
 .Button {
   background: #375eab;
   border-radius: 0.1875rem;
diff --git a/internal/relui/templates/home.html b/internal/relui/templates/home.html
index 3d87afd..60e17a8 100644
--- a/internal/relui/templates/home.html
+++ b/internal/relui/templates/home.html
@@ -4,33 +4,43 @@
     license that can be found in the LICENSE file.
 -->
 {{define "content"}}
-<section class="Workflows">
-  <div class="Workflows-header">
-    <h2>Workflows</h2>
-    <a href="/workflows/new" class="Button">New</a>
-  </div>
-  <ul class="WorkflowList">
-    {{range $workflow := .Workflows}}
-    <li class="WorkflowList-item">
-      <h3>{{$workflow.Name.String}} - {{index $workflow.ID}}</h3>
-      <h4 class="WorkflowList-sectionTitle">Tasks</h4>
-      <ul class="TaskList">
-        {{$tasks := index $.WorkflowTasks $workflow.ID}}
-        {{range $task := $tasks}}
-        <li class="TaskList-item">
-          <span class="TaskList-itemTitle">{{$task.Name}}</span>
-          Finished: {{$task.Finished}}
-          Result: {{$task.Result.String}}
-          Name: {{$task.Name}}
+  <section class="Workflows">
+    <div class="Workflows-header">
+      <h2>Workflows</h2>
+      <a href="/workflows/new" class="Button">New</a>
+    </div>
+    <ul class="WorkflowList">
+      {{range $workflow := .Workflows}}
+        <li class="WorkflowList-item">
+          <h3 class="WorkflowList-title">
+            {{$workflow.Name.String}} -
+            {{index $workflow.ID}}
+          </h3>
+          <h4 class="WorkflowList-sectionTitle">Tasks</h4>
+          <ul class="TaskList">
+            {{$tasks := index $.WorkflowTasks $workflow.ID}}
+            {{range $task := $tasks}}
+              <li class="TaskList-item">
+                <details class="TaskList-itemDetails">
+                  <summary class="TaskList-itemSummary">
+                    <span class="TaskList-itemTitle">{{$task.Name}}</span>
+                    Finished: {{$task.Finished}} Result: {{$task.Result.String}} Name:
+                    {{$task.Name}}
+                  </summary>
+                  <div class="TaskList-itemLogs">
+                    {{range $log := $.Logs $workflow.ID  $task.Name}}
+                      <div class="TaskList-itemLogLine">
+                        {{$log.CreatedAt.Format "2006/01/02 15:04:05"}}
+                        {{$log.Body}}
+                      </div>
+                    {{end}}
+                  </div>
+                </details>
+              </li>
+            {{end}}
+          </ul>
         </li>
-        {{end}}
-        <li class="TaskList-item">
-          <span class="TaskList-itemTitle">Sample Task</span>
-          Status: created
-        </li>
-      </ul>
-    </li>
-    {{end}}
-  </ul>
-</section>
+      {{end}}
+    </ul>
+  </section>
 {{end}}
diff --git a/internal/relui/templates/new_workflow.html b/internal/relui/templates/new_workflow.html
index f05f85f..bdcf547 100644
--- a/internal/relui/templates/new_workflow.html
+++ b/internal/relui/templates/new_workflow.html
@@ -7,31 +7,29 @@
   <section class="NewWorkflow">
     <h2>New Go Release</h2>
     <form action="/workflows/new" method="get">
-      <label for="workflow.name">
-        Workflow:
-      </label>
+      <label for="workflow.name">Workflow:</label>
       <select id="workflow.name" name="workflow.name" onchange="this.form.submit()">
         <option value="">Select Workflow</option>
-          {{range $name, $definition := .Definitions}}
-            <option value="{{$name}}" {{if eq $name $.Name}}selected="selected"{{end}}>
-                {{$name}}
-            </option>
-          {{end}}
+        {{range $name, $definition := .Definitions}}
+          <option value="{{$name}}" {{if eq $name $.Name}}selected="selected"{{end}}>
+            {{$name}}
+          </option>
+        {{end}}
       </select>
       <noscript>
-        <input name="workflow.new" type="submit" value="New"/>
+        <input name="workflow.new" type="submit" value="New" />
       </noscript>
     </form>
     {{if .Selected}}
       <form action="/workflows/create" method="post">
-        <input type="hidden" id="workflow.name" name="workflow.name" value="{{$.Name}}">
+        <input type="hidden" id="workflow.name" name="workflow.name" value="{{$.Name}}" />
         {{range $name := .Selected.ParameterNames}}
           <div class="NewWorkflow-Parameter">
             <label for="workflow.params.{{$name}}">{{$name}}</label>
-            <input id="workflow.params.{{$name}}" name="workflow.params.{{$name}}" value=""/>
+            <input id="workflow.params.{{$name}}" name="workflow.params.{{$name}}" value="" />
           </div>
         {{end}}
-        <input name="workflow.create" type="submit" value="Create"/>
+        <input name="workflow.create" type="submit" value="Create" />
       </form>
     {{end}}
   </section>
diff --git a/internal/relui/web.go b/internal/relui/web.go
index 7db891e..584ca29 100644
--- a/internal/relui/web.go
+++ b/internal/relui/web.go
@@ -76,29 +76,27 @@
 type homeResponse struct {
 	Workflows     []db.Workflow
 	WorkflowTasks map[uuid.UUID][]db.Task
+	TaskLogs      map[uuid.UUID]map[string][]db.TaskLog
+}
+
+func (h *homeResponse) Logs(workflow uuid.UUID, task string) []db.TaskLog {
+	t := h.TaskLogs[workflow]
+	if t == nil {
+		return nil
+	}
+	return t[task]
 }
 
 // homeHandler renders the homepage.
 func (s *Server) homeHandler(w http.ResponseWriter, r *http.Request) {
-	q := db.New(s.db)
-	ws, err := q.Workflows(r.Context())
+	resp, err := s.buildHomeResponse(r.Context())
 	if err != nil {
 		log.Printf("homeHandler: %v", err)
 		http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
 		return
 	}
-	tasks, err := q.Tasks(r.Context())
-	if err != nil {
-		log.Printf("homeHandler: %v", err)
-		http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
-		return
-	}
-	wfTasks := make(map[uuid.UUID][]db.Task, len(ws))
-	for _, t := range tasks {
-		wfTasks[t.WorkflowID] = append(wfTasks[t.WorkflowID], t)
-	}
 	out := bytes.Buffer{}
-	if err := homeTmpl.Execute(&out, homeResponse{Workflows: ws, WorkflowTasks: wfTasks}); err != nil {
+	if err := homeTmpl.Execute(&out, resp); err != nil {
 		log.Printf("homeHandler: %v", err)
 		http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
 		return
@@ -106,6 +104,34 @@
 	io.Copy(w, &out)
 }
 
+func (s *Server) buildHomeResponse(ctx context.Context) (*homeResponse, error) {
+	q := db.New(s.db)
+	ws, err := q.Workflows(ctx)
+	if err != nil {
+		return nil, err
+	}
+	tasks, err := q.Tasks(ctx)
+	if err != nil {
+		return nil, err
+	}
+	wfTasks := make(map[uuid.UUID][]db.Task, len(ws))
+	for _, t := range tasks {
+		wfTasks[t.WorkflowID] = append(wfTasks[t.WorkflowID], t)
+	}
+	tlogs, err := q.TaskLogs(ctx)
+	if err != nil {
+		return nil, err
+	}
+	wftlogs := make(map[uuid.UUID]map[string][]db.TaskLog)
+	for _, l := range tlogs {
+		if wftlogs[l.WorkflowID] == nil {
+			wftlogs[l.WorkflowID] = make(map[string][]db.TaskLog)
+		}
+		wftlogs[l.WorkflowID][l.TaskName] = append(wftlogs[l.WorkflowID][l.TaskName], l)
+	}
+	return &homeResponse{Workflows: ws, WorkflowTasks: wfTasks, TaskLogs: wftlogs}, nil
+}
+
 type newWorkflowResponse struct {
 	Definitions map[string]*workflow.Definition
 	Name        string
@@ -181,55 +207,3 @@
 	}(wf, s.db)
 	http.Redirect(w, r, "/", http.StatusSeeOther)
 }
-
-// listener implements workflow.Listener for recording workflow state.
-type listener struct {
-	db *pgxpool.Pool
-}
-
-// TaskStateChanged is called whenever a task is updated by the
-// workflow. The workflow.TaskState is persisted as a db.Task,
-// creating or updating a row as necessary.
-func (l *listener) TaskStateChanged(workflowID uuid.UUID, taskID string, state *workflow.TaskState) error {
-	log.Printf("TaskStateChanged(%q, %q, %v)", workflowID, taskID, state)
-	ctx, cancel := context.WithCancel(context.Background())
-	defer cancel()
-	result, err := json.Marshal(state.Result)
-	if err != nil {
-		return err
-	}
-	err = l.db.BeginFunc(ctx, func(tx pgx.Tx) error {
-		q := db.New(tx)
-		updated := time.Now()
-		_, err := q.UpsertTask(ctx, db.UpsertTaskParams{
-			WorkflowID: workflowID,
-			Name:       taskID,
-			Finished:   state.Finished,
-			Result:     sql.NullString{String: string(result), Valid: len(result) > 0},
-			Error:      sql.NullString{},
-			CreatedAt:  updated,
-			UpdatedAt:  updated,
-		})
-		if err != nil {
-			return err
-		}
-		return nil
-	})
-	if err != nil {
-		log.Printf("TaskStateChanged(%q, %q, %v) = %v", workflowID, taskID, state, err)
-	}
-	return err
-}
-
-func (l *listener) Logger(workflowID uuid.UUID, taskID string) workflow.Logger {
-	return &stdoutLogger{WorkflowID: workflowID, TaskID: taskID}
-}
-
-type stdoutLogger struct {
-	WorkflowID uuid.UUID
-	TaskID     string
-}
-
-func (l *stdoutLogger) Printf(format string, v ...interface{}) {
-	log.Printf("%q(%q): %v", l.WorkflowID, l.TaskID, fmt.Sprintf(format, v...))
-}
diff --git a/internal/relui/web_test.go b/internal/relui/web_test.go
index b3bd8d8..2a12386 100644
--- a/internal/relui/web_test.go
+++ b/internal/relui/web_test.go
@@ -194,7 +194,8 @@
 			desc: "successful creation",
 			params: url.Values{
 				"workflow.name":            []string{"echo"},
-				"workflow.params.greeting": []string{"abc"},
+				"workflow.params.greeting": []string{"hello"},
+				"workflow.params.farewell": []string{"bye"},
 			},
 			wantCode: http.StatusSeeOther,
 			wantHeaders: map[string]string{
@@ -203,7 +204,7 @@
 			wantWorkflows: []db.Workflow{
 				{
 					ID:        uuid.New(), // SameUUIDVariant
-					Params:    nullString(`{"greeting": "abc"}`),
+					Params:    nullString(`{"farewell": "bye", "greeting": "hello"}`),
 					Name:      nullString(`Echo`),
 					CreatedAt: time.Now(), // cmpopts.EquateApproxTime
 					UpdatedAt: time.Now(), // cmpopts.EquateApproxTime
@@ -211,8 +212,13 @@
 			},
 			wantTasks: []db.Task{
 				{
-					Name:      "echo",
-					Finished:  false,
+					Name:      "greeting",
+					Error:     sql.NullString{},
+					CreatedAt: time.Now(), // cmpopts.EquateApproxTime
+					UpdatedAt: time.Now(), // cmpopts.EquateApproxTime
+				},
+				{
+					Name:      "farewell",
 					Error:     sql.NullString{},
 					CreatedAt: time.Now(), // cmpopts.EquateApproxTime
 					UpdatedAt: time.Now(), // cmpopts.EquateApproxTime
@@ -262,8 +268,8 @@
 					cancel()
 				}
 			})
-			if len(c.wantTasks) > 0 {
-				c.wantTasks[0].WorkflowID = wfs[0].ID
+			for i := range c.wantTasks {
+				c.wantTasks[i].WorkflowID = wfs[0].ID
 			}
 			if diff := cmp.Diff(c.wantTasks, tasks, cmpopts.EquateApproxTime(time.Minute), cmpopts.IgnoreFields(db.Task{}, "Finished", "Result")); diff != "" {
 				t.Errorf("q.TasksForWorkflow(_, %q) mismatch (-want +got):\n%s", wfs[0].ID, diff)
diff --git a/internal/relui/workflows.go b/internal/relui/workflows.go
index b5b17ae..4677397 100644
--- a/internal/relui/workflows.go
+++ b/internal/relui/workflows.go
@@ -5,8 +5,6 @@
 package relui
 
 import (
-	"context"
-
 	"golang.org/x/build/internal/workflow"
 )
 
@@ -20,10 +18,13 @@
 // development.
 func newEchoWorkflow() *workflow.Definition {
 	wd := workflow.New()
-	wd.Output("greeting", wd.Task("echo", echo, wd.Parameter("greeting")))
+	greeting := wd.Task("greeting", echo, wd.Parameter("greeting"))
+	wd.Output("greeting", greeting)
+	wd.Output("farewell", wd.Task("farewell", echo, wd.Parameter("farewell")))
 	return wd
 }
 
-func echo(_ context.Context, arg string) (string, error) {
+func echo(ctx *workflow.TaskContext, arg string) (string, error) {
+	ctx.Printf("echo(%v, %q)", ctx, arg)
 	return arg, nil
 }