aboutsummaryrefslogtreecommitdiffstats
path: root/dashboard
diff options
context:
space:
mode:
authorDmitry Vyukov <dvyukov@google.com>2025-11-19 18:38:25 +0100
committerDmitry Vyukov <dvyukov@google.com>2026-01-05 09:14:02 +0000
commit77200b36494dbf8f7aa1500fbf5976585fffdb66 (patch)
treeb3b17c991bf49a7ef78b297166d26427c808e66e /dashboard
parentd65130ca2efd4a9ccb21068e3d9cefaf365e8dc6 (diff)
dashboard/app: add support for AI workflows
Support for: - polling for AI jobs - handling completion of AI jobs - submitting job trajectory logs - basic visualization for AI jobs
Diffstat (limited to 'dashboard')
-rw-r--r--dashboard/app/ai.go457
-rw-r--r--dashboard/app/ai_test.go173
-rw-r--r--dashboard/app/aidb/crud.go265
-rw-r--r--dashboard/app/aidb/entities.go54
-rw-r--r--dashboard/app/aidb/migrations/1_initialize.down.sql6
-rw-r--r--dashboard/app/aidb/migrations/1_initialize.up.sql41
-rw-r--r--dashboard/app/api.go3
-rw-r--r--dashboard/app/app_test.go29
-rw-r--r--dashboard/app/config.go2
-rw-r--r--dashboard/app/entities_datastore.go3
-rw-r--r--dashboard/app/handler.go8
-rw-r--r--dashboard/app/index.yaml6
-rw-r--r--dashboard/app/main.go32
-rw-r--r--dashboard/app/templates/ai_job.html70
-rw-r--r--dashboard/app/templates/ai_jobs.html18
-rw-r--r--dashboard/app/templates/bug.html22
-rw-r--r--dashboard/app/templates/templates.html44
-rw-r--r--dashboard/app/util_test.go15
-rw-r--r--dashboard/dashapi/ai.go53
19 files changed, 1297 insertions, 4 deletions
diff --git a/dashboard/app/ai.go b/dashboard/app/ai.go
new file mode 100644
index 000000000..e0847ea4a
--- /dev/null
+++ b/dashboard/app/ai.go
@@ -0,0 +1,457 @@
+// Copyright 2025 syzkaller project authors. All rights reserved.
+// Use of this source code is governed by Apache 2 LICENSE that can be found in the LICENSE file.
+
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "slices"
+ "strings"
+ "time"
+
+ "cloud.google.com/go/spanner"
+ "github.com/google/syzkaller/dashboard/app/aidb"
+ "github.com/google/syzkaller/dashboard/dashapi"
+ "github.com/google/syzkaller/pkg/aflow/ai"
+ "github.com/google/syzkaller/pkg/vcs"
+ db "google.golang.org/appengine/v2/datastore"
+)
+
+const AIAccessLevel = AccessUser
+
+type uiAIJobsPage struct {
+ Header *uiHeader
+ Jobs []*uiAIJob
+}
+
+type uiAIJobPage struct {
+ Header *uiHeader
+ Jobs []*uiAIJob
+ Results []*uiAIResult
+ Trajectory []*uiAITrajectorySpan
+}
+
+type uiAIJob struct {
+ ID string
+ Link string
+ Workflow string
+ Description string
+ DescriptionLink string
+
+ Created time.Time
+ Started time.Time
+ Finished time.Time
+ LLMModel string
+ CodeRevision string
+ CodeRevisionLink string
+ Error string
+}
+
+type uiAIResult struct {
+ Name string
+ Value any
+}
+
+type uiAITrajectorySpan struct {
+ Started time.Time
+ Seq int64
+ Nesting int64
+ Type string
+ Name string
+ Duration time.Duration
+ Error string
+ Args string
+ Results string
+ Instruction string
+ Prompt string
+ Reply string
+ Thoughts string
+}
+
+func handleAIJobsPage(ctx context.Context, w http.ResponseWriter, r *http.Request) error {
+ if err := checkAccessLevel(ctx, r, AIAccessLevel); err != nil {
+ return err
+ }
+ hdr, err := commonHeader(ctx, r, w, "")
+ if err != nil {
+ return err
+ }
+ jobs, err := aidb.LoadNamespaceJobs(ctx, hdr.Namespace)
+ if err != nil {
+ return err
+ }
+ var uiJobs []*uiAIJob
+ for _, job := range jobs {
+ uiJobs = append(uiJobs, makeUIAIJob(job))
+ }
+ page := &uiAIJobsPage{
+ Header: hdr,
+ Jobs: uiJobs,
+ }
+ return serveTemplate(w, "ai_jobs.html", page)
+}
+
+func handleAIJobPage(ctx context.Context, w http.ResponseWriter, r *http.Request) error {
+ if err := checkAccessLevel(ctx, r, AIAccessLevel); err != nil {
+ return err
+ }
+ job, err := aidb.LoadJob(ctx, r.FormValue("id"))
+ if err != nil {
+ return err
+ }
+ trajectory, err := aidb.LoadTrajectory(ctx, job.ID)
+ if err != nil {
+ return err
+ }
+ hdr, err := commonHeader(ctx, r, w, job.Namespace)
+ if err != nil {
+ return err
+ }
+ page := &uiAIJobPage{
+ Header: hdr,
+ Jobs: []*uiAIJob{makeUIAIJob(job)},
+ Trajectory: makeUIAITrajectory(trajectory),
+ }
+ if m, ok := job.Results.Value.(map[string]any); ok && job.Results.Valid {
+ for name, value := range m {
+ page.Results = append(page.Results, &uiAIResult{
+ Name: name,
+ Value: value,
+ })
+ }
+ }
+ slices.SortFunc(page.Results, func(a, b *uiAIResult) int {
+ return strings.Compare(a.Name, b.Name)
+ })
+ return serveTemplate(w, "ai_job.html", page)
+}
+
+func makeUIAIJob(job *aidb.Job) *uiAIJob {
+ return &uiAIJob{
+ ID: job.ID,
+ Link: fmt.Sprintf("/ai_job?id=%v", job.ID),
+ Workflow: job.Workflow,
+ Description: job.Description,
+ DescriptionLink: job.Link,
+ Created: job.Created,
+ Started: nullTime(job.Started),
+ Finished: nullTime(job.Finished),
+ LLMModel: job.LLMModel,
+ CodeRevision: job.CodeRevision,
+ CodeRevisionLink: vcs.LogLink(vcs.SyzkallerRepo, job.CodeRevision),
+ Error: job.Error,
+ }
+}
+
+func makeUIAITrajectory(trajetory []*aidb.TrajectorySpan) []*uiAITrajectorySpan {
+ var res []*uiAITrajectorySpan
+ for _, span := range trajetory {
+ 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,
+ 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),
+ })
+ }
+ return res
+}
+
+func apiAIJobPoll(ctx context.Context, req *dashapi.AIJobPollReq) (any, error) {
+ if len(req.Workflows) == 0 || req.CodeRevision == "" || req.LLMModel == "" {
+ return nil, fmt.Errorf("invalid request")
+ }
+ if err := aidb.UpdateWorkflows(ctx, req.Workflows); err != nil {
+ return nil, fmt.Errorf("failed UpdateWorkflows: %w", err)
+ }
+ job, err := aidb.StartJob(ctx, req)
+ if err != nil {
+ return nil, fmt.Errorf("failed StartJob: %w", err)
+ }
+ if job == nil {
+ if created, err := autoCreateAIJobs(ctx); err != nil || !created {
+ return &dashapi.AIJobPollResp{}, err
+ }
+ job, err = aidb.StartJob(ctx, req)
+ if err != nil {
+ return nil, fmt.Errorf("failed StartJob: %w", err)
+ }
+ if job == nil {
+ return &dashapi.AIJobPollResp{}, nil
+ }
+ }
+ args := make(map[string]any)
+ var textErr error
+ assignText := func(anyID any, tag, name string) {
+ id, err := anyID.(json.Number).Int64()
+ if err != nil {
+ textErr = err
+ }
+ if id == 0 {
+ return
+ }
+ data, _, err := getText(ctx, tag, id)
+ if err != nil {
+ textErr = err
+ }
+ args[name] = string(data)
+ }
+ if !job.Args.Valid {
+ job.Args.Value = map[string]any{}
+ }
+ for name, val := range job.Args.Value.(map[string]any) {
+ switch name {
+ case "ReproSyzID":
+ assignText(val, textReproSyz, "ReproSyz")
+ case "ReproCID":
+ assignText(val, textReproC, "ReproC")
+ case "CrashReportID":
+ assignText(val, textCrashReport, "CrashReport")
+ case "KernelConfigID":
+ assignText(val, textKernelConfig, "KernelConfig")
+ default:
+ args[name] = val
+ }
+ }
+ if textErr != nil {
+ return nil, textErr
+ }
+ return &dashapi.AIJobPollResp{
+ ID: job.ID,
+ Workflow: job.Workflow,
+ Args: args,
+ }, nil
+}
+
+func apiAIJobDone(ctx context.Context, req *dashapi.AIJobDoneReq) (any, error) {
+ job, err := aidb.LoadJob(ctx, req.ID)
+ if err != nil {
+ return nil, err
+ }
+ if job.Finished.Valid {
+ return nil, fmt.Errorf("the job %v is already finished", req.ID)
+ }
+ job.Finished = spanner.NullTime{Time: timeNow(ctx), Valid: true}
+ job.Error = req.Error[:min(len(req.Error), 4<<10)]
+ if len(req.Results) != 0 {
+ job.Results = spanner.NullJSON{Value: req.Results, Valid: true}
+ }
+ err = aidb.UpdateJob(ctx, job)
+ return nil, err
+}
+
+func apiAITrajectoryLog(ctx context.Context, req *dashapi.AITrajectoryReq) (any, error) {
+ err := aidb.StoreTrajectorySpan(ctx, req.JobID, req.Span)
+ return nil, err
+}
+
+// aiBugWorkflows returns active workflows that are applicable for the bug.
+func aiBugWorkflows(ctx context.Context, bug *Bug) ([]string, error) {
+ workflows, err := aidb.LoadWorkflows(ctx)
+ if err != nil {
+ return nil, err
+ }
+ applicable := workflowsForBug(bug, true)
+ var result []string
+ for _, flow := range workflows {
+ // Also check that the workflow is active on some syz-agent's.
+ if applicable[flow.Type] && timeSince(ctx, flow.LastActive) < 25*time.Hour {
+ result = append(result, flow.Name)
+ }
+ }
+ slices.Sort(result)
+ return result, nil
+}
+
+func aiBugJobCreate(ctx context.Context, workflow string, bug *Bug) error {
+ workflows, err := aidb.LoadWorkflows(ctx)
+ if err != nil {
+ return err
+ }
+ var typ ai.WorkflowType
+ for _, flow := range workflows {
+ if flow.Name == workflow {
+ typ = flow.Type
+ break
+ }
+ }
+ if typ == "" {
+ return fmt.Errorf("workflow %v does not exist", workflow)
+ }
+ return bugJobCreate(ctx, workflow, typ, bug)
+}
+
+func bugJobCreate(ctx context.Context, workflow string, typ ai.WorkflowType, bug *Bug) error {
+ crash, crashKey, err := findCrashForBug(ctx, bug)
+ if err != nil {
+ return err
+ }
+ build, err := loadBuild(ctx, bug.Namespace, crash.BuildID)
+ if err != nil {
+ return err
+ }
+ tx := func(ctx context.Context) error {
+ return addCrashReference(ctx, crashKey.IntID(), bug.key(ctx),
+ CrashReference{CrashReferenceAIJob, "", timeNow(ctx)})
+ }
+ if err := runInTransaction(ctx, tx, &db.TransactionOptions{
+ XG: true,
+ }); err != nil {
+ return fmt.Errorf("addCrashReference failed: %w", err)
+ }
+ return aidb.CreateJob(ctx, &aidb.Job{
+ Type: typ,
+ Workflow: workflow,
+ Namespace: bug.Namespace,
+ BugID: spanner.NullString{StringVal: bug.keyHash(ctx), Valid: true},
+ Description: bug.displayTitle(),
+ Link: fmt.Sprintf("/bug?id=%v", bug.keyHash(ctx)),
+ Args: spanner.NullJSON{Valid: true, Value: map[string]any{
+ "ReproOpts": string(crash.ReproOpts),
+ "ReproSyzID": crash.ReproSyz,
+ "ReproCID": crash.ReproC,
+ "CrashReportID": crash.Report,
+ "KernelRepo": build.KernelRepo,
+ "KernelCommit": build.KernelCommit,
+ "KernelConfigID": build.KernelConfig,
+ "SyzkallerCommit": build.SyzkallerCommit,
+ }},
+ })
+}
+
+// autoCreateAIJobs incrementally creates AI jobs for existing bugs, returns if any new jobs were created.
+//
+// The idea is as follows. We have a predicate (workflowsForBug) which says what workflows need to be
+// created for a bug. Each bug has AIJobCheck integer field, which holds version of the predicate
+// that was applied to the bug. The current/latest version is stored in currentAIJobCheckSeq.
+// We fetch some number of bugs with AIJobCheck<currentAIJobCheckSeq and check if we need to create
+// new jobs for them. The check is done by executing workflowsForBug for the bug, loading existing
+// pending/finished jobs for the bug, and finding any jobs returned by workflowsForBug that don't exist yet.
+//
+// If the predicate workflowsForBug is updated, currentAIJobCheckSeq needs to be incremented as well.
+// This will trigger immediate incremental re-checking of all existing bugs to create new jobs.
+// AIJobCheck can always be reset to 0 for a particular bug to trigger re-checking for this single bug.
+// This may be useful when, for example, a bug gets the first reproducer, and some jobs are created
+// only for bugs with reproducers. AIJobCheck may also be reset to 0 when a job finishes with an error
+// to trigger creation of a new job of the same type.
+//
+// TODO(dvyukov): figure out how to handle jobs with errors and unfinished jobs.
+// Do we want to automatically restart them or not?
+func autoCreateAIJobs(ctx context.Context) (bool, error) {
+ for ns, cfg := range getConfig(ctx).Namespaces {
+ if !cfg.AI {
+ continue
+ }
+ var bugs []*Bug
+ keys, err := db.NewQuery("Bug").
+ Filter("Namespace=", ns).
+ Filter("Status=", BugStatusOpen).
+ Filter("AIJobCheck<", currentAIJobCheckSeq).
+ Limit(100).
+ GetAll(ctx, &bugs)
+ if err != nil {
+ return false, fmt.Errorf("failed to fetch bugs: %w", err)
+ }
+ if len(bugs) == 0 {
+ continue
+ }
+ created := false
+ var updateKeys []*db.Key
+ for i, bug := range bugs {
+ updateKeys = append(updateKeys, keys[i])
+ created, err = autoCreateAIJob(ctx, bug, keys[i])
+ if err != nil {
+ return false, err
+ }
+ if created {
+ break
+ }
+ }
+ if err := updateBatch(ctx, updateKeys, func(_ *db.Key, bug *Bug) {
+ bug.AIJobCheck = currentAIJobCheckSeq
+ }); err != nil {
+ return false, err
+ }
+ if created {
+ return true, nil
+ }
+ }
+ return false, nil
+}
+
+func autoCreateAIJob(ctx context.Context, bug *Bug, bugKey *db.Key) (bool, error) {
+ workflows := workflowsForBug(bug, false)
+ if len(workflows) == 0 {
+ return false, nil
+ }
+ jobs, err := aidb.LoadBugJobs(ctx, bugKey.StringID())
+ if err != nil {
+ return false, err
+ }
+ for _, job := range jobs {
+ // Already have a pending unfinished job.
+ if !job.Finished.Valid ||
+ // Have finished successful job.
+ job.Finished.Valid && job.Error == "" {
+ delete(workflows, ai.WorkflowType(job.Workflow))
+ }
+ }
+ for workflow := range workflows {
+ if err := bugJobCreate(ctx, string(workflow), workflow, bug); err != nil {
+ return false, err
+ }
+ }
+ return len(workflows) != 0, nil
+}
+
+const currentAIJobCheckSeq = 1
+
+func workflowsForBug(bug *Bug, manual bool) map[ai.WorkflowType]bool {
+ workflows := make(map[ai.WorkflowType]bool)
+ if strings.HasPrefix(bug.Title, "KCSAN: data-race") {
+ workflows[ai.WorkflowAssessmentKCSAN] = true
+ }
+ if manual {
+ // Types we don't create automatically yet, but can be created manually.
+ if bug.HeadReproLevel > dashapi.ReproLevelNone {
+ workflows[ai.WorkflowPatching] = true
+ }
+ }
+ return workflows
+}
+
+func nullTime(v spanner.NullTime) time.Time {
+ if !v.Valid {
+ return time.Time{}
+ }
+ return v.Time
+}
+
+func nullString(v spanner.NullString) string {
+ if !v.Valid {
+ return ""
+ }
+ return v.StringVal
+}
+
+func nullJSON(v spanner.NullJSON) string {
+ if !v.Valid {
+ return ""
+ }
+ return fmt.Sprint(v.Value)
+}
diff --git a/dashboard/app/ai_test.go b/dashboard/app/ai_test.go
index 51f6142fd..b5e686dfa 100644
--- a/dashboard/app/ai_test.go
+++ b/dashboard/app/ai_test.go
@@ -5,7 +5,11 @@ package main
import (
"testing"
+ "time"
+ "github.com/google/syzkaller/dashboard/dashapi"
+ "github.com/google/syzkaller/pkg/aflow/trajectory"
+ "github.com/google/syzkaller/prog"
"github.com/stretchr/testify/require"
)
@@ -23,4 +27,173 @@ func TestAIMigrations(t *testing.T) {
require.NoError(t, executeSpannerDDL(c.ctx, down))
require.NoError(t, executeSpannerDDL(c.ctx, up))
require.NoError(t, executeSpannerDDL(c.ctx, down))
+ require.NoError(t, executeSpannerDDL(c.ctx, up))
+}
+
+func TestAIBugWorkflows(t *testing.T) {
+ c := NewSpannerCtx(t)
+ defer c.Close()
+
+ build := testBuild(1)
+ c.aiClient.UploadBuild(build)
+
+ // KCSAN bug w/o repro.
+ crash1 := testCrash(build, 1)
+ crash1.Title = "KCSAN: data-race in foo / bar"
+ c.aiClient.ReportCrash(crash1)
+ bugExtID1 := c.aiClient.pollEmailExtID()
+ kcsanBug, _, _ := c.loadBug(bugExtID1)
+
+ // Bug2: KASAN bug with repro.
+ crash2 := testCrashWithRepro(build, 2)
+ crash2.Title = "KASAN: head-use-after-free in foo"
+ c.aiClient.ReportCrash(crash2)
+ bugExtID2 := c.aiClient.pollEmailExtID()
+ kasanBug, _, _ := c.loadBug(bugExtID2)
+
+ requireWorkflows := func(bug *Bug, want []string) {
+ got, err := aiBugWorkflows(c.ctx, bug)
+ require.NoError(t, err)
+ require.Equal(t, got, want)
+ }
+ requireWorkflows(kcsanBug, nil)
+ requireWorkflows(kasanBug, nil)
+
+ _, err := c.aiClient.AIJobPoll(&dashapi.AIJobPollReq{
+ CodeRevision: prog.GitRevision,
+ LLMModel: "smarty",
+ Workflows: []dashapi.AIWorkflow{
+ {Type: "patching", Name: "patching"},
+ {Type: "patching", Name: "patching-foo"},
+ {Type: "patching", Name: "patching-bar"},
+ },
+ })
+ require.NoError(t, err)
+
+ // This should make patching-foo inactive.
+ c.advanceTime(2 * 24 * time.Hour)
+
+ _, err = c.aiClient.AIJobPoll(&dashapi.AIJobPollReq{
+ CodeRevision: prog.GitRevision,
+ LLMModel: "smarty",
+ Workflows: []dashapi.AIWorkflow{
+ {Type: "patching", Name: "patching"},
+ {Type: "patching", Name: "patching-bar"},
+ {Type: "patching", Name: "patching-baz"},
+ {Type: "assessment-kcsan", Name: "assessment-kcsan"},
+ },
+ })
+ require.NoError(t, err)
+
+ _, err = c.aiClient.AIJobPoll(&dashapi.AIJobPollReq{
+ CodeRevision: prog.GitRevision,
+ LLMModel: "smarty",
+ Workflows: []dashapi.AIWorkflow{
+ {Type: "patching", Name: "patching"},
+ {Type: "patching", Name: "patching-bar"},
+ {Type: "patching", Name: "patching-qux"},
+ {Type: "assessment-kcsan", Name: "assessment-kcsan"},
+ {Type: "assessment-kcsan", Name: "assessment-kcsan-foo"},
+ },
+ })
+ require.NoError(t, err)
+
+ requireWorkflows(kcsanBug, []string{"assessment-kcsan", "assessment-kcsan-foo"})
+ requireWorkflows(kasanBug, []string{"patching", "patching-bar", "patching-baz", "patching-qux"})
+}
+
+func TestAIJob(t *testing.T) {
+ c := NewSpannerCtx(t)
+ defer c.Close()
+
+ build := testBuild(1)
+ c.aiClient.UploadBuild(build)
+ crash := testCrash(build, 1)
+ crash.Title = "KCSAN: data-race in foo / bar"
+ c.aiClient.ReportCrash(crash)
+ c.aiClient.pollEmailBug()
+
+ resp, err := c.aiClient.AIJobPoll(&dashapi.AIJobPollReq{
+ CodeRevision: prog.GitRevision,
+ LLMModel: "smarty",
+ Workflows: []dashapi.AIWorkflow{
+ {Type: "assessment-kcsan", Name: "assessment-kcsan"},
+ },
+ })
+ require.NoError(t, err)
+ require.NotEqual(t, resp.ID, "")
+ require.Equal(t, resp.Workflow, "assessment-kcsan")
+ require.Equal(t, resp.Args, map[string]any{
+ "CrashReport": "report1",
+ "KernelRepo": "repo1",
+ "KernelCommit": "1111111111111111111111111111111111111111",
+ "KernelConfig": "config1",
+ "SyzkallerCommit": "syzkaller_commit1",
+ "ReproOpts": "",
+ })
+
+ resp2, err2 := c.aiClient.AIJobPoll(&dashapi.AIJobPollReq{
+ CodeRevision: prog.GitRevision,
+ LLMModel: "smarty",
+ Workflows: []dashapi.AIWorkflow{
+ {Type: "assessment-kcsan", Name: "assessment-kcsan"},
+ },
+ })
+ require.NoError(t, err2)
+ require.Equal(t, resp2.ID, "")
+
+ require.NoError(t, c.aiClient.AITrajectoryLog(&dashapi.AITrajectoryReq{
+ JobID: resp.ID,
+ Span: &trajectory.Span{
+ Seq: 0,
+ Type: trajectory.SpanFlow,
+ Name: "assessment-kcsan",
+ Started: c.mockedTime,
+ },
+ }))
+
+ require.NoError(t, c.aiClient.AITrajectoryLog(&dashapi.AITrajectoryReq{
+ JobID: resp.ID,
+ Span: &trajectory.Span{
+ Seq: 1,
+ Type: trajectory.SpanAgent,
+ Name: "agent",
+ Prompt: "do something",
+ Started: c.mockedTime,
+ },
+ }))
+
+ require.NoError(t, c.aiClient.AITrajectoryLog(&dashapi.AITrajectoryReq{
+ JobID: resp.ID,
+ Span: &trajectory.Span{
+ Seq: 1,
+ Type: trajectory.SpanAgent,
+ Name: "agent",
+ Prompt: "do something",
+ Started: c.mockedTime,
+ Finished: c.mockedTime.Add(time.Second),
+ Reply: "something",
+ },
+ }))
+
+ require.NoError(t, c.aiClient.AITrajectoryLog(&dashapi.AITrajectoryReq{
+ JobID: resp.ID,
+ Span: &trajectory.Span{
+ Seq: 0,
+ Type: trajectory.SpanFlow,
+ Name: "assessment-kcsan",
+ Started: c.mockedTime,
+ Finished: c.mockedTime.Add(time.Second),
+ },
+ }))
+
+ require.NoError(t, c.aiClient.AIJobDone(&dashapi.AIJobDoneReq{
+ ID: resp.ID,
+ Results: map[string]any{
+ "Patch": "patch",
+ "Explanation": "foo",
+ "Number": 1,
+ "Bool": true,
+ },
+ }))
}
diff --git a/dashboard/app/aidb/crud.go b/dashboard/app/aidb/crud.go
index 85614b05c..ccecc8e3f 100644
--- a/dashboard/app/aidb/crud.go
+++ b/dashboard/app/aidb/crud.go
@@ -3,7 +3,272 @@
package aidb
+import (
+ "context"
+ "fmt"
+ "reflect"
+ "time"
+
+ "cloud.google.com/go/spanner"
+ "github.com/google/syzkaller/dashboard/dashapi"
+ "github.com/google/syzkaller/pkg/aflow/trajectory"
+ "github.com/google/uuid"
+ "google.golang.org/appengine/v2"
+)
+
const (
Instance = "syzbot"
Database = "ai"
)
+
+func init() {
+ // This forces unmarshalling of JSON integers into json.Number rather than float64.
+ spanner.UseNumberWithJSONDecoderEncoder(true)
+}
+
+func LoadWorkflows(ctx context.Context) ([]*Workflow, error) {
+ return selectAll[Workflow](ctx, spanner.Statement{
+ SQL: `SELECT * FROM Workflows`,
+ })
+}
+
+func UpdateWorkflows(ctx context.Context, active []dashapi.AIWorkflow) error {
+ workflows, err := LoadWorkflows(ctx)
+ if err != nil {
+ return err
+ }
+ m := make(map[string]*Workflow)
+ for _, f := range workflows {
+ m[f.Name] = f
+ }
+ // Truncate the time so that we don't need to update the database on each poll.
+ nowDate := TimeNow(ctx).Truncate(24 * time.Hour)
+ var mutations []*spanner.Mutation
+ for _, f := range active {
+ flow := &Workflow{
+ Name: f.Name,
+ Type: f.Type,
+ LastActive: nowDate,
+ }
+ if have := m[flow.Name]; reflect.DeepEqual(have, flow) {
+ continue
+ }
+ mut, err := spanner.InsertOrUpdateStruct("Workflows", flow)
+ if err != nil {
+ return err
+ }
+ mutations = append(mutations, mut)
+ }
+ if len(mutations) == 0 {
+ return nil
+ }
+ client, err := dbClient(ctx)
+ if err != nil {
+ return err
+ }
+ defer client.Close()
+ _, err = client.Apply(ctx, mutations)
+ return err
+}
+
+func CreateJob(ctx context.Context, job *Job) error {
+ job.ID = uuid.NewString()
+ job.Created = TimeNow(ctx)
+ client, err := dbClient(ctx)
+ if err != nil {
+ return err
+ }
+ mut, err := spanner.InsertStruct("Jobs", job)
+ if err != nil {
+ return err
+ }
+ _, err = client.Apply(ctx, []*spanner.Mutation{mut})
+ return err
+}
+
+func UpdateJob(ctx context.Context, job *Job) error {
+ client, err := dbClient(ctx)
+ if err != nil {
+ return err
+ }
+ mut, err := spanner.UpdateStruct("Jobs", job)
+ if err != nil {
+ return err
+ }
+ _, err = client.Apply(ctx, []*spanner.Mutation{mut})
+ return err
+}
+
+func StartJob(ctx context.Context, req *dashapi.AIJobPollReq) (*Job, error) {
+ var workflows []string
+ for _, flow := range req.Workflows {
+ workflows = append(workflows, flow.Name)
+ }
+ client, err := dbClient(ctx)
+ if err != nil {
+ return nil, err
+ }
+ defer client.Close()
+ var job *Job
+ _, err = client.ReadWriteTransaction(ctx, func(ctx context.Context, txn *spanner.ReadWriteTransaction) error {
+ {
+ iter := txn.Query(ctx, spanner.Statement{
+ SQL: `SELECT * FROM Jobs WHERE Workflow IN UNNEST(@workflows)
+ AND Started IS NULL
+ ORDER BY Created ASC LIMIT 1`,
+ Params: map[string]any{
+ "workflows": workflows,
+ },
+ })
+ defer iter.Stop()
+ var jobs []*Job
+ if err := spanner.SelectAll(iter, &jobs); err != nil || len(jobs) == 0 {
+ return err
+ }
+ job = jobs[0]
+ }
+ job.Started = spanner.NullTime{
+ Time: TimeNow(ctx),
+ Valid: true,
+ }
+ job.LLMModel = req.LLMModel
+ job.CodeRevision = req.CodeRevision
+ mut, err := spanner.InsertOrUpdateStruct("Jobs", job)
+ if err != nil {
+ return err
+ }
+ return txn.BufferWrite([]*spanner.Mutation{mut})
+ })
+ return job, err
+}
+
+func LoadNamespaceJobs(ctx context.Context, ns string) ([]*Job, error) {
+ return selectAll[Job](ctx, spanner.Statement{
+ SQL: `SELECT * FROM Jobs WHERE Namespace = @ns ORDER BY Created DESC`,
+ Params: map[string]any{
+ "ns": ns,
+ },
+ })
+}
+
+func LoadBugJobs(ctx context.Context, bugID string) ([]*Job, error) {
+ return selectAll[Job](ctx, spanner.Statement{
+ SQL: `SELECT * FROM Jobs WHERE BugID = @bugID ORDER BY Created DESC`,
+ Params: map[string]any{
+ "bugID": bugID,
+ },
+ })
+}
+
+func LoadJob(ctx context.Context, id string) (*Job, error) {
+ return selectOne[Job](ctx, spanner.Statement{
+ SQL: `SELECT * FROM Jobs WHERE ID = @id`,
+ Params: map[string]any{
+ "id": id,
+ },
+ })
+}
+
+func StoreTrajectorySpan(ctx context.Context, jobID string, span *trajectory.Span) error {
+ client, err := dbClient(ctx)
+ if err != nil {
+ return err
+ }
+ defer client.Close()
+ ent := TrajectorySpan{
+ JobID: jobID,
+ Seq: int64(span.Seq),
+ Nesting: int64(span.Nesting),
+ Type: string(span.Type),
+ Name: span.Name,
+ Started: span.Started,
+ Finished: toNullTime(span.Finished),
+ Error: toNullString(span.Error),
+ Args: toNullJSON(span.Args),
+ Results: toNullJSON(span.Results),
+ Instruction: toNullString(span.Instruction),
+ Prompt: toNullString(span.Prompt),
+ Reply: toNullString(span.Reply),
+ Thoughts: toNullString(span.Thoughts),
+ }
+ mut, err := spanner.InsertOrUpdateStruct("TrajectorySpans", ent)
+ if err != nil {
+ return err
+ }
+ _, err = client.Apply(ctx, []*spanner.Mutation{mut})
+ return err
+}
+
+func LoadTrajectory(ctx context.Context, jobID string) ([]*TrajectorySpan, error) {
+ return selectAll[TrajectorySpan](ctx, spanner.Statement{
+ SQL: `SELECT * FROM TrajectorySpans WHERE JobID = @job_id ORDER BY Seq ASC`,
+ Params: map[string]any{
+ "job_id": jobID,
+ },
+ })
+}
+
+func selectAll[T any](ctx context.Context, stmt spanner.Statement) ([]*T, error) {
+ client, err := dbClient(ctx)
+ if err != nil {
+ return nil, err
+ }
+ defer client.Close()
+ iter := client.Single().Query(ctx, stmt)
+ defer iter.Stop()
+ var items []*T
+ err = spanner.SelectAll(iter, &items)
+ if err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+func selectOne[T any](ctx context.Context, stmt spanner.Statement) (*T, error) {
+ all, err := selectAll[T](ctx, stmt)
+ if err != nil {
+ return nil, err
+ }
+ if len(all) != 1 {
+ return nil, fmt.Errorf("selectOne: got %v of %T", len(all), *new(T))
+ }
+ return all[0], nil
+}
+
+func dbClient(ctx context.Context) (*spanner.Client, error) {
+ path := fmt.Sprintf("projects/%v/instances/%v/databases/%v",
+ appengine.AppID(ctx), Instance, Database)
+ // TODO(dvyukov): create a persistent client with a pool of connections for prod,
+ // but keep transient/per-test clients for tests.
+ return spanner.NewClientWithConfig(ctx, path, spanner.ClientConfig{
+ SessionPoolConfig: spanner.SessionPoolConfig{
+ MinOpened: 1,
+ MaxOpened: 1,
+ },
+ })
+}
+
+var TimeNow = func(ctx context.Context) time.Time {
+ return time.Now()
+}
+
+func toNullJSON(v map[string]any) spanner.NullJSON {
+ if v == nil {
+ return spanner.NullJSON{}
+ }
+ return spanner.NullJSON{Value: v, Valid: true}
+}
+
+func toNullTime(v time.Time) spanner.NullTime {
+ if v.IsZero() {
+ return spanner.NullTime{}
+ }
+ return spanner.NullTime{Time: v, Valid: true}
+}
+
+func toNullString(v string) spanner.NullString {
+ if v == "" {
+ return spanner.NullString{}
+ }
+ return spanner.NullString{StringVal: v, Valid: true}
+}
diff --git a/dashboard/app/aidb/entities.go b/dashboard/app/aidb/entities.go
new file mode 100644
index 000000000..49a69d0c4
--- /dev/null
+++ b/dashboard/app/aidb/entities.go
@@ -0,0 +1,54 @@
+// Copyright 2025 syzkaller project authors. All rights reserved.
+// Use of this source code is governed by Apache 2 LICENSE that can be found in the LICENSE file.
+
+package aidb
+
+import (
+ "time"
+
+ "cloud.google.com/go/spanner"
+ "github.com/google/syzkaller/pkg/aflow/ai"
+)
+
+type Workflow struct {
+ Name string
+ Type ai.WorkflowType
+ LastActive time.Time
+}
+
+type Job struct {
+ ID string
+ Type ai.WorkflowType
+ Workflow string
+ Namespace string
+ BugID spanner.NullString // set if the job related to some bug
+ // Arbitrary description/link shown in the UI list of jobs.
+ Description string
+ Link string
+ Created time.Time
+ Started spanner.NullTime
+ Finished spanner.NullTime
+ LLMModel string // LLM model used to execute the job, filled when the job is started
+ CodeRevision string // syzkaller revision, filled when the job is started
+ Error string // for finished jobs
+ Args spanner.NullJSON
+ Results spanner.NullJSON
+}
+
+type TrajectorySpan struct {
+ JobID string
+ // The following fields correspond one-to-one to trajectory.Span fields (add field comments there).
+ Seq int64
+ Nesting int64
+ Type string
+ Name string
+ Started time.Time
+ Finished spanner.NullTime
+ Error spanner.NullString
+ Args spanner.NullJSON
+ Results spanner.NullJSON
+ Instruction spanner.NullString
+ Prompt spanner.NullString
+ Reply spanner.NullString
+ Thoughts spanner.NullString
+}
diff --git a/dashboard/app/aidb/migrations/1_initialize.down.sql b/dashboard/app/aidb/migrations/1_initialize.down.sql
index d65b9e742..8c76df3cb 100644
--- a/dashboard/app/aidb/migrations/1_initialize.down.sql
+++ b/dashboard/app/aidb/migrations/1_initialize.down.sql
@@ -1 +1,5 @@
-DROP TABLE Test;
+ALTER TABLE TrajectorySpans DROP CONSTRAINT FK_EventJob;
+
+DROP TABLE Workflows;
+DROP TABLE Jobs;
+DROP TABLE TrajectorySpans;
diff --git a/dashboard/app/aidb/migrations/1_initialize.up.sql b/dashboard/app/aidb/migrations/1_initialize.up.sql
index 981cd8d3a..4448242b1 100644
--- a/dashboard/app/aidb/migrations/1_initialize.up.sql
+++ b/dashboard/app/aidb/migrations/1_initialize.up.sql
@@ -1,3 +1,42 @@
-CREATE TABLE Test (
+CREATE TABLE Workflows (
Name STRING(1000) NOT NULL,
+ Type STRING(1000) NOT NULL,
+ LastActive TIMESTAMP NOT NULL,
) PRIMARY KEY (Name);
+
+CREATE TABLE Jobs (
+ ID STRING(36) NOT NULL,
+ Type STRING(1000) NOT NULL,
+ Workflow STRING(1000) NOT NULL,
+ Namespace STRING(1000) NOT NULL,
+ BugID STRING(1000),
+ Description STRING(1000) NOT NULL,
+ Link STRING(1000) NOT NULL,
+ Created TIMESTAMP NOT NULL,
+ Started TIMESTAMP,
+ Finished TIMESTAMP,
+ LLMModel STRING(1000),
+ CodeRevision STRING(1000),
+ Error STRING(MAX),
+ Args JSON,
+ Results JSON,
+) PRIMARY KEY (ID);
+
+CREATE TABLE TrajectorySpans (
+ JobID STRING(36) NOT NULL,
+ Seq INT64 NOT NULL,
+ Nesting INT64 NOT NULL,
+ Type STRING(1000) NOT NULL,
+ Name STRING(1000) NOT NULL,
+ Started TIMESTAMP NOT NULL,
+ Finished TIMESTAMP,
+ Error STRING(MAX),
+ Args JSON,
+ Results JSON,
+ Instruction STRING(MAX),
+ Prompt STRING(MAX),
+ Reply STRING(MAX),
+ Thoughts STRING(MAX),
+
+ CONSTRAINT FK_EventJob FOREIGN KEY (JobID) REFERENCES Jobs (ID),
+) PRIMARY KEY (JobID, Seq);
diff --git a/dashboard/app/api.go b/dashboard/app/api.go
index c7539c5f5..6d883d312 100644
--- a/dashboard/app/api.go
+++ b/dashboard/app/api.go
@@ -60,6 +60,9 @@ var apiHandlers = map[string]APIHandler{
"save_discussion": typedHandler(apiSaveDiscussion),
"create_upload_url": typedHandler(apiCreateUploadURL),
"send_email": typedHandler(apiSendEmail),
+ "ai_job_poll": typedHandler(apiAIJobPoll),
+ "ai_job_done": typedHandler(apiAIJobDone),
+ "ai_trajectory_log": typedHandler(apiAITrajectoryLog),
"save_coverage": gcsPayloadHandler(apiSaveCoverage),
"upload_build": nsHandler(apiUploadBuild),
"builder_poll": nsHandler(apiBuilderPoll),
diff --git a/dashboard/app/app_test.go b/dashboard/app/app_test.go
index 693ca61ee..d4c9f765f 100644
--- a/dashboard/app/app_test.go
+++ b/dashboard/app/app_test.go
@@ -604,6 +604,33 @@ var testConfig = &GlobalConfig{
},
},
},
+ "ains": {
+ AI: true,
+ AccessLevel: AccessPublic,
+ Key: "publickeypublickeypublickey",
+ Clients: map[string]string{
+ clientAI: keyAI,
+ },
+ Repos: []KernelRepo{
+ {
+ URL: "git://syzkaller.org/test.git",
+ Branch: "main",
+ Alias: "main",
+ },
+ },
+ Reporting: []Reporting{
+ {
+ AccessLevel: AccessPublic,
+ Name: "ai-email-reporting",
+ DailyLimit: 1000,
+ Config: &EmailConfig{
+ Email: "test@syzkaller.com",
+ HandleListEmails: true,
+ SubjectPrefix: "[syzbot]",
+ },
+ },
+ },
+ },
},
}
@@ -654,6 +681,8 @@ const (
keySubsystemRemind = "keySubsystemRemindkeySubsystemRemind"
clientTreeTests = "clientTreeTestsclientTreeTests"
keyTreeTests = "keyTreeTestskeyTreeTestskeyTreeTests"
+ clientAI = "client-ai"
+ keyAI = "clientaikeyclientaikeyclientaikey"
restrictedManager = "restricted-manager"
noFixBisectionManager = "no-fix-bisection-manager"
diff --git a/dashboard/app/config.go b/dashboard/app/config.go
index 1b32d96a8..b0a49f9dd 100644
--- a/dashboard/app/config.go
+++ b/dashboard/app/config.go
@@ -87,6 +87,8 @@ type Config struct {
// If set, this namespace is not actively tested, no notifications are sent, etc.
// It's kept mostly read-only for historical reference.
Decommissioned bool
+ // Enable AI workflows for the namespace.
+ AI bool
// Name used in UI.
DisplayTitle string
// Unique string that allows to show "similar bugs" across different namespaces.
diff --git a/dashboard/app/entities_datastore.go b/dashboard/app/entities_datastore.go
index e424172f4..370cf3ceb 100644
--- a/dashboard/app/entities_datastore.go
+++ b/dashboard/app/entities_datastore.go
@@ -131,6 +131,7 @@ type Bug struct {
// FixCandidateJob holds the key of the latest successful cross-tree fix bisection job.
FixCandidateJob string
ReproAttempts []BugReproAttempt
+ AIJobCheck int64
}
type BugTreeTestInfo struct {
@@ -382,6 +383,7 @@ type CrashReferenceType string
const (
CrashReferenceReporting = "reporting"
CrashReferenceJob = "job"
+ CrashReferenceAIJob = "ai_job"
// This one is needed for backward compatibility.
crashReferenceUnknown = "unknown"
)
@@ -390,6 +392,7 @@ type CrashReference struct {
Type CrashReferenceType
// For CrashReferenceReporting, it refers to Reporting.Name
// For CrashReferenceJob, it refers to extJobID(jobKey)
+ // For CrashReferenceAIJob, empty string
Key string
Time time.Time
}
diff --git a/dashboard/app/handler.go b/dashboard/app/handler.go
index bda86eb30..1acd9ed4d 100644
--- a/dashboard/app/handler.go
+++ b/dashboard/app/handler.go
@@ -214,6 +214,7 @@ func serveTemplate(w http.ResponseWriter, name string, data any) error {
type uiHeader struct {
Admin bool
+ AI bool // Enable UI elements related to AI
URLPath string
LoginLink string
AnalyticsTrackingID string
@@ -225,6 +226,7 @@ type uiHeader struct {
Namespaces []uiNamespace
ShowSubsystems bool
ShowCoverageMenu bool
+ Message string // Show message box with this message when the page loads.
}
type uiNamespace struct {
@@ -299,8 +301,10 @@ func commonHeader(c context.Context, r *http.Request, w http.ResponseWriter, ns
}
}
if ns != adminPage {
+ cfg := getNsConfig(c, ns)
h.Namespace = ns
- h.ShowSubsystems = getNsConfig(c, ns).Subsystems.Service != nil
+ h.AI = cfg.AI && accessLevel >= AIAccessLevel
+ h.ShowSubsystems = cfg.Subsystems.Service != nil
cookie.Namespace = ns
encodeCookie(w, cookie)
cached, err := CacheGet(c, r, ns)
@@ -309,7 +313,7 @@ func commonHeader(c context.Context, r *http.Request, w http.ResponseWriter, ns
}
h.BugCounts = &cached.Total
h.MissingBackports = cached.MissingBackports
- h.ShowCoverageMenu = getNsConfig(c, ns).Coverage != nil
+ h.ShowCoverageMenu = cfg.Coverage != nil
}
return h, nil
}
diff --git a/dashboard/app/index.yaml b/dashboard/app/index.yaml
index eae3d3c13..9f524ebb2 100644
--- a/dashboard/app/index.yaml
+++ b/dashboard/app/index.yaml
@@ -129,6 +129,12 @@ indexes:
- name: Status
- name: FixCandidateJob
+- kind: Bug
+ properties:
+ - name: Namespace
+ - name: Status
+ - name: AIJobCheck
+
- kind: Build
properties:
- name: Namespace
diff --git a/dashboard/app/main.go b/dashboard/app/main.go
index ce281a4fb..c3ca24763 100644
--- a/dashboard/app/main.go
+++ b/dashboard/app/main.go
@@ -20,6 +20,7 @@ import (
"time"
"cloud.google.com/go/logging/logadmin"
+ "github.com/google/syzkaller/dashboard/app/aidb"
"github.com/google/syzkaller/dashboard/dashapi"
"github.com/google/syzkaller/pkg/debugtracer"
"github.com/google/syzkaller/pkg/email"
@@ -46,6 +47,7 @@ func initHTTPHandlers() {
http.Handle("/", handlerWrapper(handleMain))
http.Handle("/bug", handlerWrapper(handleBug))
http.Handle("/text", handlerWrapper(handleText))
+ http.Handle("/ai_job", handlerWrapper(handleAIJobPage))
http.Handle("/admin", handlerWrapper(handleAdmin))
http.Handle("/x/.config", handlerWrapper(handleTextX(textKernelConfig)))
http.Handle("/x/log.txt", handlerWrapper(handleTextX(textCrashLog)))
@@ -82,6 +84,7 @@ func initHTTPHandlers() {
http.Handle("/"+ns+"/backports", handlerWrapper(handleBackports))
http.Handle("/"+ns+"/s/", handlerWrapper(handleSubsystemPage))
http.Handle("/"+ns+"/manager/", handlerWrapper(handleManagerPage))
+ http.Handle("/"+ns+"/ai/", handlerWrapper(handleAIJobsPage))
}
http.HandleFunc("/cron/cache_update", cacheUpdate)
http.HandleFunc("/cron/minute_cache_update", handleMinuteCacheUpdate)
@@ -294,6 +297,8 @@ type uiBugPage struct {
LabelGroups []*uiBugLabelGroup
DebugSubsystems string
Bug *uiBugDetails
+ AIWorkflows []string
+ AIJobs []*uiAIJob
}
type uiBugDetails struct {
@@ -1170,6 +1175,21 @@ func handleBug(c context.Context, w http.ResponseWriter, r *http.Request) error
Value: reproAttempts,
})
}
+ var aiWorkflows []string
+ var aiJobs []*uiAIJob
+ if hdr.AI {
+ aiWorkflows, err = aiBugWorkflows(c, bug)
+ if err != nil {
+ return err
+ }
+ jobs, err := aidb.LoadBugJobs(c, bug.keyHash(c))
+ if err != nil {
+ return err
+ }
+ for _, job := range jobs {
+ aiJobs = append(aiJobs, makeUIAIJob(job))
+ }
+ }
data := &uiBugPage{
Header: hdr,
Now: timeNow(c),
@@ -1177,6 +1197,8 @@ func handleBug(c context.Context, w http.ResponseWriter, r *http.Request) error
LabelGroups: getLabelGroups(c, bug),
Crashes: crashesTable,
Bug: bugDetails,
+ AIWorkflows: aiWorkflows,
+ AIJobs: aiJobs,
}
if accessLevel == AccessAdmin && !bug.hasUserSubsystems() {
data.DebugSubsystems = urlutil.SetParam(data.Bug.Link, "debug_subsystems", "1")
@@ -1194,6 +1216,16 @@ func handleBug(c context.Context, w http.ResponseWriter, r *http.Request) error
data.Sections = append(data.Sections, makeCollapsibleBugJobs(
"Cause bisection attempts", bugDetails.BisectCauseJobs))
}
+
+ if workflow := r.FormValue("ai-job-create"); workflow != "" {
+ if !hdr.AI {
+ return ErrAccess
+ }
+ if err := aiBugJobCreate(c, workflow, bug); err != nil {
+ return err
+ }
+ hdr.Message = fmt.Sprintf("AI workflow %v is created", workflow)
+ }
if r.FormValue("json") == "1" {
w.Header().Set("Content-Type", "application/json")
return writeJSONVersionOf(w, data)
diff --git a/dashboard/app/templates/ai_job.html b/dashboard/app/templates/ai_job.html
new file mode 100644
index 000000000..3aaad429a
--- /dev/null
+++ b/dashboard/app/templates/ai_job.html
@@ -0,0 +1,70 @@
+{{/*
+Copyright 2025 syzkaller project authors. All rights reserved.
+Use of this source code is governed by Apache 2 LICENSE that can be found in the LICENSE file.
+
+Detailed info on a single AI job execution.
+*/}}
+
+<!doctype html>
+<html>
+<head>
+ {{template "head" .Header}}
+ <title>syzbot</title>
+</head>
+<body>
+ {{template "header" .Header}}
+ {{template "ai_job_list" .Jobs}}
+
+ {{range $res := .Results}}
+ <br><b>{{$res.Name}}:</b><br>
+ <div id="ai_result_div"><pre>{{$res.Value}}</pre></div><br>
+ {{end}}
+
+ <table class="list_table">
+ <caption>Trajectory:</caption>
+ <thead><tr>
+ <th>Seq</th>
+ <th>Timestamp</th>
+ <th>Type</th>
+ <th>Name</th>
+ <th>Duration</th>
+ </tr></thead>
+ <tbody>
+ {{range $span := $.Trajectory}}
+ <tr>
+ <td>{{$span.Seq}}/{{$span.Nesting}}</td>
+ <td>{{formatTime $span.Started}}</td>
+ <td>{{$span.Type}}</td>
+ <td>{{$span.Name}}</td>
+ <td>
+ <details>
+ <summary>{{formatDuration $span.Duration}}</summary>
+ {{if $span.Error}}
+ <b>Error:</b> <div id="ai_details_div"><pre>{{$span.Error}}</pre></div><br>
+ {{end}}
+ {{if $span.Args}}
+ <b>Args:</b> <div id="ai_details_div"><pre>{{$span.Args}}</pre></div><br>
+ {{end}}
+ {{if $span.Results}}
+ <b>Results:</b> <div id="ai_details_div"><pre>{{$span.Results}}</pre></div><br>
+ {{end}}
+ {{if $span.Instruction}}
+ <b>Instruction:</b> <div id="ai_details_div"><pre>{{$span.Instruction}}</pre></div><br>
+ {{end}}
+ {{if $span.Prompt}}
+ <b>Prompt:</b> <div id="ai_details_div"><pre>{{$span.Prompt}}</pre></div><br>
+ {{end}}
+ {{if $span.Reply}}
+ <b>Reply:</b> <div id="ai_details_div"><pre>{{$span.Reply}}</pre></div><br>
+ {{end}}
+ {{if $span.Thoughts}}
+ <b>Thoughts:</b> <div id="ai_details_div"><pre>{{$span.Thoughts}}</pre></div><br>
+ {{end}}
+ </details>
+ </td>
+ </tr>
+ {{end}}
+ </tbody>
+ </table>
+</body>
+</html>
diff --git a/dashboard/app/templates/ai_jobs.html b/dashboard/app/templates/ai_jobs.html
new file mode 100644
index 000000000..966eeefdf
--- /dev/null
+++ b/dashboard/app/templates/ai_jobs.html
@@ -0,0 +1,18 @@
+{{/*
+Copyright 2025 syzkaller project authors. All rights reserved.
+Use of this source code is governed by Apache 2 LICENSE that can be found in the LICENSE file.
+
+List of AI jobs page.
+*/}}
+
+<!doctype html>
+<html>
+<head>
+ {{template "head" .Header}}
+ <title>syzbot</title>
+</head>
+<body>
+ {{template "header" .Header}}
+ {{template "ai_job_list" .Jobs}}
+</body>
+</html>
diff --git a/dashboard/app/templates/bug.html b/dashboard/app/templates/bug.html
index 94de735b4..697cd94ca 100644
--- a/dashboard/app/templates/bug.html
+++ b/dashboard/app/templates/bug.html
@@ -38,6 +38,28 @@ Page with details about a single bug.
{{end}}
{{end}}
First crash: {{formatLateness $.Now $.Bug.FirstTime}}, last: {{formatLateness $.Now $.Bug.LastTime}}<br>
+ {{if .AIWorkflows}}
+ <form method="POST">
+ <select name="ai-job-create">
+ {{range $flow := .AIWorkflows}}
+ <option>{{$flow}}</option>
+ {{end}}
+ </select>
+ <input type="submit" value="✨ Create"/>
+ </form>
+ {{end}}
+ {{if .AIJobs}}
+ <div class="collapsible collapsible-show">
+ <div class="head">
+ <span class="show-icon">&#9654;</span>
+ <span class="hide-icon">&#9660;</span>
+ <span>✨ AI Jobs ({{len .AIJobs}})</span>
+ </div>
+ <div class="content">
+ {{template "ai_job_list" .AIJobs}}
+ </div>
+ </div>
+ {{end}}
{{if .Bug.FixCandidateJob}}
<div class="fix-candidate-block">{{template "bisect_results" .Bug.FixCandidateJob}}</div>
{{end}}
diff --git a/dashboard/app/templates/templates.html b/dashboard/app/templates/templates.html
index 3301ce804..e469c18b3 100644
--- a/dashboard/app/templates/templates.html
+++ b/dashboard/app/templates/templates.html
@@ -115,6 +115,11 @@ Use of this source code is governed by Apache 2 LICENSE that can be found in the
</div>
</div>
{{end}}
+ {{if .AI}}
+ <div class="navigation_tab{{if eq .URLPath (printf "/%v/ai" $.Namespace)}}_selected{{end}}">
+ <a href='/{{$.Namespace}}/ai'>✨ AI</a>
+ </div>
+ {{end}}
{{if .ContactEmail}}
<div class="navigation_tab">
<a href='mailto:{{.ContactEmail}}'><span style="color:ForestGreen;">💬</span> Send us feedback</a>
@@ -124,6 +129,13 @@ Use of this source code is governed by Apache 2 LICENSE that can be found in the
{{end}}
</header>
<br>
+ {{if .Message}}
+ <script>
+ document.addEventListener("DOMContentLoaded", function() {
+ alert("{{.Message}}");
+ });
+ </script>
+ {{end}}
{{end}}
{{/* List of enabled filters, invoked with *uiBugFilter */}}
@@ -674,3 +686,35 @@ Use of this source code is governed by Apache 2 LICENSE that can be found in the
</table>
{{end}}
{{end}}
+
+{{/* List of AI jobs, invoked with []*uiAIJob */}}
+{{define "ai_job_list"}}
+<table class="list_table">
+ <thead><tr>
+ <th><a onclick="return sortTable(this, 'ID', textSort)" href="#">ID</a></th>
+ <th><a onclick="return sortTable(this, 'Workflow', textSort)" href="#">Workflow</a></th>
+ <th><a onclick="return sortTable(this, 'Description', textSort)" href="#">Description</a></th>
+ <th><a onclick="return sortTable(this, 'Created', textSort)" href="#">Created</a></th>
+ <th><a onclick="return sortTable(this, 'Started', textSort)" href="#">Started</a></th>
+ <th><a onclick="return sortTable(this, 'Finished', textSort)" href="#">Finished</a></th>
+ <th><a onclick="return sortTable(this, 'Model', textSort)" href="#">Model</a></th>
+ <th><a onclick="return sortTable(this, 'Revision', textSort)" href="#">Revision</a></th>
+ <th><a onclick="return sortTable(this, 'Error', textSort)" href="#">Error</a></th>
+ </tr></thead>
+ <tbody>
+ {{range $job := .}}
+ <tr>
+ <td>{{link $job.Link $job.ID}}</td>
+ <td>{{$job.Workflow}}</td>
+ <td>{{link $job.DescriptionLink $job.Description}}</td>
+ <td>{{formatTime $job.Created}}</td>
+ <td>{{formatTime $job.Started}}</td>
+ <td>{{formatTime $job.Finished}}</td>
+ <td>{{$job.LLMModel}}</td>
+ <td>{{link $job.CodeRevisionLink $job.CodeRevision}}</td>
+ <td>{{$job.Error}}</td>
+ </tr>
+ {{end}}
+ </tbody>
+</table>
+{{end}}
diff --git a/dashboard/app/util_test.go b/dashboard/app/util_test.go
index eb18a38a9..71a78a7f1 100644
--- a/dashboard/app/util_test.go
+++ b/dashboard/app/util_test.go
@@ -54,6 +54,8 @@ type Ctx struct {
client *apiClient
client2 *apiClient
publicClient *apiClient
+ aiClient *apiClient
+ checkAI bool
}
var skipDevAppserverTests = func() bool {
@@ -92,10 +94,12 @@ func newCtx(t *testing.T, appID string) *Ctx {
mockedTime: time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC),
emailSink: make(chan *aemail.Message, 100),
transformContext: func(c context.Context) context.Context { return c },
+ checkAI: appID != "",
}
c.client = c.makeClient(client1, password1, true)
c.client2 = c.makeClient(client2, password2, true)
c.publicClient = c.makeClient(clientPublicEmail, keyPublicEmail, true)
+ c.aiClient = c.makeClient(clientAI, keyAI, true)
c.ctx = registerRequest(r, c).Context()
return c
}
@@ -261,6 +265,16 @@ func (c *Ctx) Close() {
for _, rep := range resp.Reports {
c.t.Errorf("ERROR: leftover external report:\n%#v", rep)
}
+ if c.checkAI {
+ _, err = c.GET("/ains/ai/")
+ c.expectOK(err)
+ jobs, err := aidb.LoadNamespaceJobs(c.ctx, "ains")
+ c.expectOK(err)
+ for _, job := range jobs {
+ _, err = c.GET(fmt.Sprintf("/ai_job?id=%v", job.ID))
+ c.expectOK(err)
+ }
+ }
}
unregisterContext(c)
validateGlobalConfig()
@@ -732,6 +746,7 @@ func initMocks() {
timeNow = func(c context.Context) time.Time {
return getRequestContext(c).mockedTime
}
+ aidb.TimeNow = timeNow
sendEmail = func(c context.Context, msg *aemail.Message) error {
getRequestContext(c).emailSink <- msg
return nil
diff --git a/dashboard/dashapi/ai.go b/dashboard/dashapi/ai.go
new file mode 100644
index 000000000..893ed7215
--- /dev/null
+++ b/dashboard/dashapi/ai.go
@@ -0,0 +1,53 @@
+// Copyright 2025 syzkaller project authors. All rights reserved.
+// Use of this source code is governed by Apache 2 LICENSE that can be found in the LICENSE file.
+
+package dashapi
+
+import (
+ "github.com/google/syzkaller/pkg/aflow/ai"
+ "github.com/google/syzkaller/pkg/aflow/trajectory"
+)
+
+type AIJobPollReq struct {
+ LLMModel string // LLM model that will be used to execute jobs
+ CodeRevision string // git commit of the syz-agent server
+ Workflows []AIWorkflow
+}
+
+type AIWorkflow struct {
+ Type ai.WorkflowType
+ Name string
+}
+
+type AIJobPollResp struct {
+ ID string
+ Workflow string
+ Args map[string]any
+}
+
+type AIJobDoneReq struct {
+ ID string
+ Error string
+ Results map[string]any
+}
+
+type AITrajectoryReq struct {
+ JobID string
+ Span *trajectory.Span
+}
+
+func (dash *Dashboard) AIJobPoll(req *AIJobPollReq) (*AIJobPollResp, error) {
+ resp := new(AIJobPollResp)
+ if err := dash.Query("ai_job_poll", req, resp); err != nil {
+ return nil, err
+ }
+ return resp, nil
+}
+
+func (dash *Dashboard) AIJobDone(req *AIJobDoneReq) error {
+ return dash.Query("ai_job_done", req, nil)
+}
+
+func (dash *Dashboard) AITrajectoryLog(req *AITrajectoryReq) error {
+ return dash.Query("ai_trajectory_log", req, nil)
+}