diff options
| author | Dmitry Vyukov <dvyukov@google.com> | 2025-11-19 18:38:25 +0100 |
|---|---|---|
| committer | Dmitry Vyukov <dvyukov@google.com> | 2026-01-05 09:14:02 +0000 |
| commit | 77200b36494dbf8f7aa1500fbf5976585fffdb66 (patch) | |
| tree | b3b17c991bf49a7ef78b297166d26427c808e66e /dashboard | |
| parent | d65130ca2efd4a9ccb21068e3d9cefaf365e8dc6 (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.go | 457 | ||||
| -rw-r--r-- | dashboard/app/ai_test.go | 173 | ||||
| -rw-r--r-- | dashboard/app/aidb/crud.go | 265 | ||||
| -rw-r--r-- | dashboard/app/aidb/entities.go | 54 | ||||
| -rw-r--r-- | dashboard/app/aidb/migrations/1_initialize.down.sql | 6 | ||||
| -rw-r--r-- | dashboard/app/aidb/migrations/1_initialize.up.sql | 41 | ||||
| -rw-r--r-- | dashboard/app/api.go | 3 | ||||
| -rw-r--r-- | dashboard/app/app_test.go | 29 | ||||
| -rw-r--r-- | dashboard/app/config.go | 2 | ||||
| -rw-r--r-- | dashboard/app/entities_datastore.go | 3 | ||||
| -rw-r--r-- | dashboard/app/handler.go | 8 | ||||
| -rw-r--r-- | dashboard/app/index.yaml | 6 | ||||
| -rw-r--r-- | dashboard/app/main.go | 32 | ||||
| -rw-r--r-- | dashboard/app/templates/ai_job.html | 70 | ||||
| -rw-r--r-- | dashboard/app/templates/ai_jobs.html | 18 | ||||
| -rw-r--r-- | dashboard/app/templates/bug.html | 22 | ||||
| -rw-r--r-- | dashboard/app/templates/templates.html | 44 | ||||
| -rw-r--r-- | dashboard/app/util_test.go | 15 | ||||
| -rw-r--r-- | dashboard/dashapi/ai.go | 53 |
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">▶</span> + <span class="hide-icon">▼</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) +} |
