Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 115 additions & 39 deletions dashboard/app/ai.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"html/template"
"net/http"
"slices"
"strconv"
"strings"
"time"

Expand All @@ -30,15 +31,8 @@ type uiAIJobsPage struct {
Jobs []*uiAIJob
Workflows []string
CurrentWorkflow string
}

type uiAIJobPage struct {
Header *uiHeader
Job *uiAIJob
// The slice contains the same single Job, just for HTML templates convenience.
Jobs []*uiAIJob
CrashReport template.HTML
Trajectory []*uiAITrajectorySpan
Page int
ShowNext bool
}

type uiAIJob struct {
Expand All @@ -63,6 +57,20 @@ type uiAIResult struct {
Value any
}

type uiAIJobPage struct {
Header *uiHeader
Job *uiAIJob
// The slice contains the same single Job, just for HTML templates convenience.
Jobs []*uiAIJob
CrashReport template.HTML
Trajectory []*uiAITrajectoryNode
}

type uiAITrajectoryNode struct {
*uiAITrajectorySpan
Children []*uiAITrajectoryNode
}

type uiAITrajectorySpan struct {
Started time.Time
Seq int64
Expand Down Expand Up @@ -91,10 +99,24 @@ func handleAIJobsPage(ctx context.Context, w http.ResponseWriter, r *http.Reques
if err != nil {
return err
}
jobs, err := aidb.LoadNamespaceJobs(ctx, hdr.Namespace)
pageStr := r.FormValue("page")
pageNum := 1
if pageStr != "" {
if n, err := strconv.Atoi(pageStr); err == nil && n > 1 {
pageNum = n
}
}
const itemsPerPage = 20
jobs, err := aidb.LoadNamespaceJobs(ctx, hdr.Namespace, itemsPerPage+1, (pageNum-1)*itemsPerPage)
if err != nil {
return err
}
showNext := false
if len(jobs) > itemsPerPage {
showNext = true
jobs = jobs[:itemsPerPage]
}

workflowParam := r.FormValue("workflow")
var uiJobs []*uiAIJob
for _, job := range jobs {
Expand All @@ -117,6 +139,8 @@ func handleAIJobsPage(ctx context.Context, w http.ResponseWriter, r *http.Reques
Jobs: uiJobs,
Workflows: workflowNames,
CurrentWorkflow: workflowParam,
Page: pageNum,
ShowNext: showNext,
}
return serveTemplate(w, "ai_jobs.html", page)
}
Expand Down Expand Up @@ -158,12 +182,14 @@ func handleAIJobPage(ctx context.Context, w http.ResponseWriter, r *http.Request
args = job.Args.Value.(map[string]any)
}
var crashReport template.HTML
if reportID, _ := args["CrashReportID"].(json.Number).Int64(); reportID != 0 {
report, _, err := getText(ctx, textCrashReport, reportID)
if err != nil {
return err
if val, ok := args["CrashReportID"]; ok {
if reportID, _ := val.(json.Number).Int64(); reportID != 0 {
report, _, err := getText(ctx, textCrashReport, reportID)
if err != nil {
return err
}
crashReport = linkifyReport(report, args["KernelRepo"].(string), args["KernelCommit"].(string))
}
crashReport = linkifyReport(report, args["KernelRepo"].(string), args["KernelCommit"].(string))
}
uiJob := makeUIAIJob(job)
page := &uiAIJobPage{
Expand Down Expand Up @@ -228,34 +254,81 @@ func makeUIAIJob(job *aidb.Job) *uiAIJob {
}
}

func makeUIAITrajectory(trajetory []*aidb.TrajectorySpan) []*uiAITrajectorySpan {
var res []*uiAITrajectorySpan
for _, span := range trajetory {
func makeUIAITrajectory(trajectory []*aidb.TrajectorySpan) []*uiAITrajectoryNode {
// We need to reconstruct the tree from the flat list of spans.
// We assume that the spans are sorted by Seq and Nesting is consistent.
// We use a stack to keep track of the current path in the tree.
// The stack contains pointers to the nodes.
// stack[0] is the root (virtual).
// stack[i] is the parent of stack[i+1].
// root is a virtual node to hold top-level nodes.
root := &uiAITrajectoryNode{}
stack := []*uiAITrajectoryNode{root}

for _, span := range trajectory {
var duration time.Duration
if span.Finished.Valid {
duration = span.Finished.Time.Sub(span.Started)
}
res = append(res, &uiAITrajectorySpan{
Started: span.Started,
Seq: span.Seq,
Nesting: span.Nesting,
Type: span.Type,
Name: span.Name,
Model: span.Model,
Duration: duration,
Error: nullString(span.Error),
Args: nullJSON(span.Args),
Results: nullJSON(span.Results),
Instruction: nullString(span.Instruction),
Prompt: nullString(span.Prompt),
Reply: nullString(span.Reply),
Thoughts: nullString(span.Thoughts),
InputTokens: nullInt64(span.InputTokens),
OutputTokens: nullInt64(span.OutputTokens),
OutputThoughtsTokens: nullInt64(span.OutputThoughtsTokens),
})
}
return res
node := &uiAITrajectoryNode{
uiAITrajectorySpan: &uiAITrajectorySpan{
Started: span.Started,
Seq: span.Seq,
Nesting: span.Nesting,
Type: span.Type,
Name: span.Name,
Model: span.Model,
Duration: duration,
Error: nullString(span.Error),
Args: nullJSON(span.Args),
Results: nullJSON(span.Results),
Instruction: nullString(span.Instruction),
Prompt: nullString(span.Prompt),
Reply: nullString(span.Reply),
Thoughts: nullString(span.Thoughts),
InputTokens: nullInt64(span.InputTokens),
OutputTokens: nullInt64(span.OutputTokens),
OutputThoughtsTokens: nullInt64(span.OutputThoughtsTokens),
},
}

// Adjust stack to the correct nesting level.
// Nesting 0 means direct children of root (stack len 1)
// Nesting N means direct children of stack[N] (stack len N+1)
targetLen := int(span.Nesting) + 1
targetLen = max(targetLen, 1)
// This implies missing intermediate levels or jump in nesting.
// Ideally shouldn't happen for strict tree traversal, but we just append to current parent.
targetLen = min(targetLen, len(stack))
stack = stack[:targetLen]

parent := stack[len(stack)-1]
parent.Children = append(parent.Children, node)
stack = append(stack, node)
}

// Propagate errors specifically for:
// "If an agent only has a single entry that's a failed tool call, it should be considered failed / red as well."
var propagateErrors func(nodes []*uiAITrajectoryNode)
propagateErrors = func(nodes []*uiAITrajectoryNode) {
for _, node := range nodes {
if len(node.Children) > 0 {
propagateErrors(node.Children)
// If this node is an Agent (or just a parent) and has exactly 1 child
// and that child has an Error, propagate it if the parent doesn't have an error.
if len(node.Children) == 1 && node.Children[0].Error != "" && node.Error == "" {
// We copy the child's error to indicate failure at this level too.
// Or maybe we want a distinct message? The user said "considered failed / red as well".
// Copying the error makes it show up in the UI as if this node failed.
// Let's use a pointer or just copy the string.
node.Error = node.Children[0].Error
}
}
}
}
propagateErrors(root.Children)

return root.Children
}

func apiAIJobPoll(ctx context.Context, req *dashapi.AIJobPollReq) (any, error) {
Expand Down Expand Up @@ -646,6 +719,9 @@ func nullJSON(v spanner.NullJSON) string {
if !v.Valid {
return ""
}
if b, err := json.Marshal(v.Value); err == nil {
return string(b)
}
return fmt.Sprint(v.Value)
}

Expand Down
172 changes: 172 additions & 0 deletions dashboard/app/ai_render_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
package main

import (
"html/template"
"os"
"path/filepath"
"strings"
"testing"
"time"

"cloud.google.com/go/spanner"
"github.com/stretchr/testify/require"
)

func TestAIRender(t *testing.T) {
// Mock specific data for nested flows
// We want to simulate a flow that calls a sub-agent, which calls another sub-agent.

now := time.Now()

// Construct the trajectory as a forest (list of trees)
trajectory := []*uiAITrajectoryNode{
{
uiAITrajectorySpan: &uiAITrajectorySpan{
Seq: 1,
Nesting: 0,
Type: "flow",
Name: "Root Flow",
Started: now.Add(-10 * time.Second),
Duration: 10 * time.Second,
},
Children: []*uiAITrajectoryNode{
{
uiAITrajectorySpan: &uiAITrajectorySpan{
Seq: 2,
Nesting: 1,
Type: "agent",
Name: "Coordinator Agent",
Started: now.Add(-9 * time.Second),
Duration: 8 * time.Second,
Prompt: "Coordinate the task",
Reply: "Task coordinated",
},
Children: []*uiAITrajectoryNode{
{
uiAITrajectorySpan: &uiAITrajectorySpan{
Seq: 3,
Nesting: 2,
Type: "tool",
Name: "Search",
Started: now.Add(-8 * time.Second),
Duration: 1 * time.Second,
Args: ` {
"query": "syzkaller",
"options": {
"verbose": true,
"filters": ["linux", "kernel", "fuzzing"],
"advanced": {
"depth": 5,
"strategy": "recursive",
"empty_check": "",
"meta": {
"author": "dmnk",
"timestamp": 1234567890
}
}
}
}`,
Results: `{"count": 100, "empty_result": ""}`,
},
},
{
uiAITrajectorySpan: &uiAITrajectorySpan{
Seq: 4,
Nesting: 2,
Type: "flow",
Name: "Sub Flow (Analysis)",
Started: now.Add(-6 * time.Second),
Duration: 4 * time.Second,
},
Children: []*uiAITrajectoryNode{
{
uiAITrajectorySpan: &uiAITrajectorySpan{
Seq: 5,
Nesting: 3,
Type: "agent",
Name: "Analyzer Agent",
Started: now.Add(-5 * time.Second),
Duration: 2 * time.Second,
Thoughts: "Analyzing the crash dump...",
},
},
},
},
},
},
},
},
}

page := &uiAIJobPage{
Header: &uiHeader{Namespace: "test-ns", BugCounts: &CachedBugStats{}},
Job: &uiAIJob{
ID: "test-job-id",
Correct: "❓",
},
Trajectory: trajectory,
CrashReport: template.HTML("Crash report content"),
}

// Create output file in a known location for manual inspection.
outDir := "/tmp/syzkaller_ai_test"
if err := os.MkdirAll(outDir, 0755); err != nil {
t.Fatal(err)
}
outFile := filepath.Join(outDir, "ai_job_render.html")
f, err := os.Create(outFile)
require.NoError(t, err)
defer f.Close()

// We need to use valid template paths.
// Since we are running the test from dashboard/app, strict paths might be needed?
// The templates variable uses glob search path.
// In test environment, current directory is dashboard/app.
// We might need to make sure pkg/html can find the templates.
// Usually dashboard/app tests assume templates are in templates/ (local dir) or similar.
// Let's assume templates are loaded correctly if we use the main package's 'templates' var.
// However, 'templates' var initialization might fail if it can't find files.
// We might need to fix GlobSearchPath if it defaults incorrectly.
// pkg/html defaults to "templates/" for non-appengine.
// if CWD is dashboard/app, then templates/ is right there.

err = templates.ExecuteTemplate(f, "ai_job.html", page)
require.NoError(t, err)

t.Logf("rendered template to: %s", outFile)

// Also write to a persistent location for verification.
persistentFile := "ai_job_debug.html"
pf, err := os.Create(persistentFile)
require.NoError(t, err)
defer pf.Close()
templates.ExecuteTemplate(pf, "ai_job.html", page)

// Read back and verify output file.
content, err := os.ReadFile(outFile)
require.NoError(t, err)
require.Contains(t, string(content), "node-1-root-flow",
"Output should contain slugified ID for Root Flow")
require.Contains(t, string(content), "node-2-coordinator-agent",
"Output should contain slugified ID for Coordinator Agent")
require.Contains(t, string(content), `<span class="json-empty">empty</span>`,
"Output should contain styled empty string in Args")
// We added empty_result to Results, so we expect it to appear twice or just verify it appears.
require.Equal(t, 2, strings.Count(string(content), `<span class="json-empty">empty</span>`),
"Output should contain styled empty string in both Args and Results")
}

func TestNullJSON(t *testing.T) {
m := map[string]any{"foo": "bar", "count": 42}
val := spanner.NullJSON{Value: m, Valid: true}
// nullJSON is unexported, ensuring we can test it in the same package
got := nullJSON(val)

// We expect valid JSON string.
// Check for presence of key/value pairs in JSON format
require.Contains(t, got, `"foo":"bar"`)
require.Contains(t, got, `"count":42`)
if strings.Contains(got, "map[") {
t.Errorf("nullJSON returned map string representation: %q", got)
}
}
Loading
Loading