diff --git a/dashboard/app/ai.go b/dashboard/app/ai.go index c80c5005c8f2..21cb5c732ef4 100644 --- a/dashboard/app/ai.go +++ b/dashboard/app/ai.go @@ -11,6 +11,7 @@ import ( "html/template" "net/http" "slices" + "strconv" "strings" "time" @@ -30,15 +31,8 @@ type uiAIJobsPage struct { Jobs []*uiAIJob Workflows []string CurrentWorkflow string -} - -type uiAIJobPage struct { - Header *uiHeader - Job *uiAIJob - // The slice contains the same single Job, just for HTML templates convenience. - Jobs []*uiAIJob - CrashReport template.HTML - Trajectory []*uiAITrajectorySpan + Page int + ShowNext bool } type uiAIJob struct { @@ -63,6 +57,20 @@ type uiAIResult struct { Value any } +type uiAIJobPage struct { + Header *uiHeader + Job *uiAIJob + // The slice contains the same single Job, just for HTML templates convenience. + Jobs []*uiAIJob + CrashReport template.HTML + Trajectory []*uiAITrajectoryNode +} + +type uiAITrajectoryNode struct { + *uiAITrajectorySpan + Children []*uiAITrajectoryNode +} + type uiAITrajectorySpan struct { Started time.Time Seq int64 @@ -91,10 +99,24 @@ func handleAIJobsPage(ctx context.Context, w http.ResponseWriter, r *http.Reques if err != nil { return err } - jobs, err := aidb.LoadNamespaceJobs(ctx, hdr.Namespace) + pageStr := r.FormValue("page") + pageNum := 1 + if pageStr != "" { + if n, err := strconv.Atoi(pageStr); err == nil && n > 1 { + pageNum = n + } + } + const itemsPerPage = 20 + jobs, err := aidb.LoadNamespaceJobs(ctx, hdr.Namespace, itemsPerPage+1, (pageNum-1)*itemsPerPage) if err != nil { return err } + showNext := false + if len(jobs) > itemsPerPage { + showNext = true + jobs = jobs[:itemsPerPage] + } + workflowParam := r.FormValue("workflow") var uiJobs []*uiAIJob for _, job := range jobs { @@ -117,6 +139,8 @@ func handleAIJobsPage(ctx context.Context, w http.ResponseWriter, r *http.Reques Jobs: uiJobs, Workflows: workflowNames, CurrentWorkflow: workflowParam, + Page: pageNum, + ShowNext: showNext, } return serveTemplate(w, "ai_jobs.html", page) } @@ -158,12 +182,14 @@ func handleAIJobPage(ctx context.Context, w http.ResponseWriter, r *http.Request args = job.Args.Value.(map[string]any) } var crashReport template.HTML - if reportID, _ := args["CrashReportID"].(json.Number).Int64(); reportID != 0 { - report, _, err := getText(ctx, textCrashReport, reportID) - if err != nil { - return err + if val, ok := args["CrashReportID"]; ok { + if reportID, _ := val.(json.Number).Int64(); reportID != 0 { + report, _, err := getText(ctx, textCrashReport, reportID) + if err != nil { + return err + } + crashReport = linkifyReport(report, args["KernelRepo"].(string), args["KernelCommit"].(string)) } - crashReport = linkifyReport(report, args["KernelRepo"].(string), args["KernelCommit"].(string)) } uiJob := makeUIAIJob(job) page := &uiAIJobPage{ @@ -228,34 +254,81 @@ func makeUIAIJob(job *aidb.Job) *uiAIJob { } } -func makeUIAITrajectory(trajetory []*aidb.TrajectorySpan) []*uiAITrajectorySpan { - var res []*uiAITrajectorySpan - for _, span := range trajetory { +func makeUIAITrajectory(trajectory []*aidb.TrajectorySpan) []*uiAITrajectoryNode { + // We need to reconstruct the tree from the flat list of spans. + // We assume that the spans are sorted by Seq and Nesting is consistent. + // We use a stack to keep track of the current path in the tree. + // The stack contains pointers to the nodes. + // stack[0] is the root (virtual). + // stack[i] is the parent of stack[i+1]. + // root is a virtual node to hold top-level nodes. + root := &uiAITrajectoryNode{} + stack := []*uiAITrajectoryNode{root} + + for _, span := range trajectory { var duration time.Duration if span.Finished.Valid { duration = span.Finished.Time.Sub(span.Started) } - res = append(res, &uiAITrajectorySpan{ - Started: span.Started, - Seq: span.Seq, - Nesting: span.Nesting, - Type: span.Type, - Name: span.Name, - Model: span.Model, - Duration: duration, - Error: nullString(span.Error), - Args: nullJSON(span.Args), - Results: nullJSON(span.Results), - Instruction: nullString(span.Instruction), - Prompt: nullString(span.Prompt), - Reply: nullString(span.Reply), - Thoughts: nullString(span.Thoughts), - InputTokens: nullInt64(span.InputTokens), - OutputTokens: nullInt64(span.OutputTokens), - OutputThoughtsTokens: nullInt64(span.OutputThoughtsTokens), - }) - } - return res + node := &uiAITrajectoryNode{ + uiAITrajectorySpan: &uiAITrajectorySpan{ + Started: span.Started, + Seq: span.Seq, + Nesting: span.Nesting, + Type: span.Type, + Name: span.Name, + Model: span.Model, + Duration: duration, + Error: nullString(span.Error), + Args: nullJSON(span.Args), + Results: nullJSON(span.Results), + Instruction: nullString(span.Instruction), + Prompt: nullString(span.Prompt), + Reply: nullString(span.Reply), + Thoughts: nullString(span.Thoughts), + InputTokens: nullInt64(span.InputTokens), + OutputTokens: nullInt64(span.OutputTokens), + OutputThoughtsTokens: nullInt64(span.OutputThoughtsTokens), + }, + } + + // Adjust stack to the correct nesting level. + // Nesting 0 means direct children of root (stack len 1) + // Nesting N means direct children of stack[N] (stack len N+1) + targetLen := int(span.Nesting) + 1 + targetLen = max(targetLen, 1) + // This implies missing intermediate levels or jump in nesting. + // Ideally shouldn't happen for strict tree traversal, but we just append to current parent. + targetLen = min(targetLen, len(stack)) + stack = stack[:targetLen] + + parent := stack[len(stack)-1] + parent.Children = append(parent.Children, node) + stack = append(stack, node) + } + + // Propagate errors specifically for: + // "If an agent only has a single entry that's a failed tool call, it should be considered failed / red as well." + var propagateErrors func(nodes []*uiAITrajectoryNode) + propagateErrors = func(nodes []*uiAITrajectoryNode) { + for _, node := range nodes { + if len(node.Children) > 0 { + propagateErrors(node.Children) + // If this node is an Agent (or just a parent) and has exactly 1 child + // and that child has an Error, propagate it if the parent doesn't have an error. + if len(node.Children) == 1 && node.Children[0].Error != "" && node.Error == "" { + // We copy the child's error to indicate failure at this level too. + // Or maybe we want a distinct message? The user said "considered failed / red as well". + // Copying the error makes it show up in the UI as if this node failed. + // Let's use a pointer or just copy the string. + node.Error = node.Children[0].Error + } + } + } + } + propagateErrors(root.Children) + + return root.Children } func apiAIJobPoll(ctx context.Context, req *dashapi.AIJobPollReq) (any, error) { @@ -646,6 +719,9 @@ func nullJSON(v spanner.NullJSON) string { if !v.Valid { return "" } + if b, err := json.Marshal(v.Value); err == nil { + return string(b) + } return fmt.Sprint(v.Value) } diff --git a/dashboard/app/ai_render_test.go b/dashboard/app/ai_render_test.go new file mode 100644 index 000000000000..7f48bc0e103a --- /dev/null +++ b/dashboard/app/ai_render_test.go @@ -0,0 +1,172 @@ +package main + +import ( + "html/template" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "cloud.google.com/go/spanner" + "github.com/stretchr/testify/require" +) + +func TestAIRender(t *testing.T) { + // Mock specific data for nested flows + // We want to simulate a flow that calls a sub-agent, which calls another sub-agent. + + now := time.Now() + + // Construct the trajectory as a forest (list of trees) + trajectory := []*uiAITrajectoryNode{ + { + uiAITrajectorySpan: &uiAITrajectorySpan{ + Seq: 1, + Nesting: 0, + Type: "flow", + Name: "Root Flow", + Started: now.Add(-10 * time.Second), + Duration: 10 * time.Second, + }, + Children: []*uiAITrajectoryNode{ + { + uiAITrajectorySpan: &uiAITrajectorySpan{ + Seq: 2, + Nesting: 1, + Type: "agent", + Name: "Coordinator Agent", + Started: now.Add(-9 * time.Second), + Duration: 8 * time.Second, + Prompt: "Coordinate the task", + Reply: "Task coordinated", + }, + Children: []*uiAITrajectoryNode{ + { + uiAITrajectorySpan: &uiAITrajectorySpan{ + Seq: 3, + Nesting: 2, + Type: "tool", + Name: "Search", + Started: now.Add(-8 * time.Second), + Duration: 1 * time.Second, + Args: ` { + "query": "syzkaller", + "options": { + "verbose": true, + "filters": ["linux", "kernel", "fuzzing"], + "advanced": { + "depth": 5, + "strategy": "recursive", + "empty_check": "", + "meta": { + "author": "dmnk", + "timestamp": 1234567890 + } + } + } + }`, + Results: `{"count": 100, "empty_result": ""}`, + }, + }, + { + uiAITrajectorySpan: &uiAITrajectorySpan{ + Seq: 4, + Nesting: 2, + Type: "flow", + Name: "Sub Flow (Analysis)", + Started: now.Add(-6 * time.Second), + Duration: 4 * time.Second, + }, + Children: []*uiAITrajectoryNode{ + { + uiAITrajectorySpan: &uiAITrajectorySpan{ + Seq: 5, + Nesting: 3, + Type: "agent", + Name: "Analyzer Agent", + Started: now.Add(-5 * time.Second), + Duration: 2 * time.Second, + Thoughts: "Analyzing the crash dump...", + }, + }, + }, + }, + }, + }, + }, + }, + } + + page := &uiAIJobPage{ + Header: &uiHeader{Namespace: "test-ns", BugCounts: &CachedBugStats{}}, + Job: &uiAIJob{ + ID: "test-job-id", + Correct: "❓", + }, + Trajectory: trajectory, + CrashReport: template.HTML("Crash report content"), + } + + // Create output file in a known location for manual inspection. + outDir := "/tmp/syzkaller_ai_test" + if err := os.MkdirAll(outDir, 0755); err != nil { + t.Fatal(err) + } + outFile := filepath.Join(outDir, "ai_job_render.html") + f, err := os.Create(outFile) + require.NoError(t, err) + defer f.Close() + + // We need to use valid template paths. + // Since we are running the test from dashboard/app, strict paths might be needed? + // The templates variable uses glob search path. + // In test environment, current directory is dashboard/app. + // We might need to make sure pkg/html can find the templates. + // Usually dashboard/app tests assume templates are in templates/ (local dir) or similar. + // Let's assume templates are loaded correctly if we use the main package's 'templates' var. + // However, 'templates' var initialization might fail if it can't find files. + // We might need to fix GlobSearchPath if it defaults incorrectly. + // pkg/html defaults to "templates/" for non-appengine. + // if CWD is dashboard/app, then templates/ is right there. + + err = templates.ExecuteTemplate(f, "ai_job.html", page) + require.NoError(t, err) + + t.Logf("rendered template to: %s", outFile) + + // Also write to a persistent location for verification. + persistentFile := "ai_job_debug.html" + pf, err := os.Create(persistentFile) + require.NoError(t, err) + defer pf.Close() + templates.ExecuteTemplate(pf, "ai_job.html", page) + + // Read back and verify output file. + content, err := os.ReadFile(outFile) + require.NoError(t, err) + require.Contains(t, string(content), "node-1-root-flow", + "Output should contain slugified ID for Root Flow") + require.Contains(t, string(content), "node-2-coordinator-agent", + "Output should contain slugified ID for Coordinator Agent") + require.Contains(t, string(content), `empty`, + "Output should contain styled empty string in Args") + // We added empty_result to Results, so we expect it to appear twice or just verify it appears. + require.Equal(t, 2, strings.Count(string(content), `empty`), + "Output should contain styled empty string in both Args and Results") +} + +func TestNullJSON(t *testing.T) { + m := map[string]any{"foo": "bar", "count": 42} + val := spanner.NullJSON{Value: m, Valid: true} + // nullJSON is unexported, ensuring we can test it in the same package + got := nullJSON(val) + + // We expect valid JSON string. + // Check for presence of key/value pairs in JSON format + require.Contains(t, got, `"foo":"bar"`) + require.Contains(t, got, `"count":42`) + if strings.Contains(got, "map[") { + t.Errorf("nullJSON returned map string representation: %q", got) + } +} diff --git a/dashboard/app/ai_test.go b/dashboard/app/ai_test.go index 5bdb9202a9e5..20bde4770276 100644 --- a/dashboard/app/ai_test.go +++ b/dashboard/app/ai_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "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/aflow/trajectory" @@ -286,3 +287,42 @@ func TestAIJobsFiltering(t *testing.T) { require.NoError(t, err) require.NotContains(t, string(resp), "KCSAN: data-race") } + +func TestAIJobsPagination(t *testing.T) { + c := NewSpannerCtx(t) + defer c.Close() + + // Direct DB insertion is faster and enough for this test. + for i := 0; i < 25; i++ { + c.advanceTime(time.Minute) + job := &aidb.Job{ + Type: ai.WorkflowAssessmentKCSAN, + Workflow: "assessment-kcsan", + Namespace: "ains", + Description: fmt.Sprintf("Job %d", i), + } + require.NoError(t, aidb.CreateJob(c.ctx, job)) + } + + // Page 1: Should have 20 items (itemsPerPage). + resp, err := c.GET("/ains/ai") + require.NoError(t, err) + // We expect 20 items. logic: 25 items total. + // Sorted by Created DESC. + // Page 1: 24, 23, ..., 5 (20 items). + // Page 2: 4, 3, 2, 1, 0 (5 items). + + // Check content. + require.Contains(t, string(resp), "Job 24") + require.Contains(t, string(resp), "Job 5") + require.NotContains(t, string(resp), "Job 4") + require.Contains(t, string(resp), "Next") + + // Page 2: + resp, err = c.GET("/ains/ai?page=2") + require.NoError(t, err) + require.Contains(t, string(resp), "Job 4") + require.Contains(t, string(resp), "Job 0") + require.NotContains(t, string(resp), "Job 5") + require.Contains(t, string(resp), "Previous") +} diff --git a/dashboard/app/aidb/crud.go b/dashboard/app/aidb/crud.go index 424f376ee1fd..428b630871c2 100644 --- a/dashboard/app/aidb/crud.go +++ b/dashboard/app/aidb/crud.go @@ -138,11 +138,13 @@ func StartJob(ctx context.Context, req *dashapi.AIJobPollReq) (*Job, error) { return job, err } -func LoadNamespaceJobs(ctx context.Context, ns string) ([]*Job, error) { +func LoadNamespaceJobs(ctx context.Context, ns string, limit, offset int) ([]*Job, error) { return selectAll[Job](ctx, spanner.Statement{ - SQL: selectJobs() + `WHERE Namespace = @ns ORDER BY Created DESC`, + SQL: selectJobs() + `WHERE Namespace = @ns ORDER BY Created DESC LIMIT @limit OFFSET @offset`, Params: map[string]any{ - "ns": ns, + "ns": ns, + "limit": limit, + "offset": offset, }, }) } diff --git a/dashboard/app/templates/ai_job.html b/dashboard/app/templates/ai_job.html index e1971ac3dcc9..e9a39f4b9a2c 100644 --- a/dashboard/app/templates/ai_job.html +++ b/dashboard/app/templates/ai_job.html @@ -46,61 +46,425 @@
{{.CrashReport}}

{{end}} - - - - - - - - - - - {{range $span := $.Trajectory}} - - - - - - - + + + {{end}} - -
Trajectory:
SeqTimestampTypeNameDuration
{{$span.Seq}}/{{$span.Nesting}}{{formatTime $span.Started}}{{$span.Type}}{{$span.Name}} -
- {{formatDuration $span.Duration}} - {{if $span.Model}} - Model:
{{$span.Model}}

- {{end}} - {{if $span.Error}} - Error:
{{$span.Error}}

- {{end}} - {{if $span.Args}} - Args:
{{$span.Args}}

- {{end}} - {{if $span.Results}} - Results:
{{$span.Results}}

- {{end}} - {{if $span.Instruction}} - Instruction:
{{$span.Instruction}}

- {{end}} - {{if $span.Prompt}} - Prompt:
{{$span.Prompt}}

+ + +

Trajectory

+
+ {{template "trajectory_node" .Trajectory}} +
+ + {{define "json_view"}} + {{if isJSONKV .}} +
+ {{range .}} +
+ {{if or (isJSONKV .Value) (isSlice .Value)}} +
+ {{.Key}} +
+ {{template "json_view" .Value}} +
+
+ {{else}} +
+
{{.Key}}
+
+ {{template "json_view" .Value}} +
+
+ {{end}} +
+ {{end}} +
+ {{else if isSlice .}} +
    + {{range .}}
  • {{template "json_view" .}}
  • {{end}} +
+ {{else}} + {{formatJSONValue .}} + {{end}} + {{end}} + + {{define "trajectory_node"}} + {{range .}} +
+
+ +
+ {{.Type}} + {{.Name}} + + {{formatTime .Started}} + {{if .Duration}}({{formatDuration .Duration}}){{end}} + + 🔗 +
+
+
+ {{if .Error}} +
+
Error
+
{{.Error}}
+
+ {{end}} + {{if .Prompt}} +
+
Prompt
+
{{.Prompt}}
+
+ {{end}} + {{/* Thoughts: If we have other content (Prompt/Reply), collapse it. Otherwise show it. */}} + {{if .Thoughts}} + {{if or .Prompt .Reply}} +
+ Thoughts +
{{.Thoughts}}
+
+ {{else}} +
+
Thoughts
+
{{.Thoughts}}
+
+ {{end}} + {{end}} + {{if .Reply}} +
+
Reply
+
{{.Reply}}
+
+ {{end}} + {{if .Args}} +
+
Args
+ {{with .Args | jsonParse}} +
{{template "json_view" .}}
+ {{else}} +
{{.Args}}
{{end}} - {{if $span.Reply}} - Reply:
{{$span.Reply}}

+
+ {{end}} + {{if .Results}} +
+
Results
+ {{with .Results | jsonParse}} +
{{template "json_view" .}}
+ {{else}} +
{{.Results}}
{{end}} - {{if $span.InputTokens}} - Tokens:
-							input: {{$span.InputTokens}}
-							output: {{$span.OutputTokens}}
-							thoughts: {{$span.OutputThoughtsTokens}}
-						

+
+ {{end}} + {{if .InputTokens}} +
+
Tokens
+ In: {{.InputTokens}}, Out: {{.OutputTokens}} +
{{end}} - {{if $span.Thoughts}} - Thoughts:
{{$span.Thoughts}}

+ {{if .Children}} +
+ {{template "trajectory_node" .Children}} +
{{end}} -
-
+ {{end}} + +``` diff --git a/dashboard/app/templates/ai_jobs.html b/dashboard/app/templates/ai_jobs.html index e36c9af43391..033c94a3bdf4 100644 --- a/dashboard/app/templates/ai_jobs.html +++ b/dashboard/app/templates/ai_jobs.html @@ -24,5 +24,18 @@ {{template "ai_job_list" .Jobs}} + {{if or (gt .Page 1) .ShowNext}} +
+ {{if gt .Page 1}} + Previous + {{end}} + {{if and (gt .Page 1) .ShowNext}} + | + {{end}} + {{if .ShowNext}} + Next + {{end}} +
+ {{end}} diff --git a/dashboard/app/util_test.go b/dashboard/app/util_test.go index d2862bd220d3..da8721b8003e 100644 --- a/dashboard/app/util_test.go +++ b/dashboard/app/util_test.go @@ -293,7 +293,7 @@ func (ctx *Ctx) Close() { if ctx.checkAI { _, err = ctx.GET("/ains/ai/") ctx.expectOK(err) - jobs, err := aidb.LoadNamespaceJobs(ctx.ctx, "ains") + jobs, err := aidb.LoadNamespaceJobs(ctx.ctx, "ains", 10, 0) ctx.expectOK(err) for _, job := range jobs { _, err = ctx.GET(fmt.Sprintf("/ai_job?id=%v", job.ID)) diff --git a/pkg/html/html.go b/pkg/html/html.go index 0da3674ea749..76b7bc4c10cc 100644 --- a/pkg/html/html.go +++ b/pkg/html/html.go @@ -4,10 +4,13 @@ package html import ( + "bytes" + "encoding/json" "fmt" "html/template" "path/filepath" "reflect" + "regexp" "strings" texttemplate "text/template" "time" @@ -70,6 +73,122 @@ var Funcs = template.FuncMap{ "selectBisect": selectBisect, "dereference": dereferencePointer, "commitLink": commitLink, + "tryFormatJSON": tryFormatJSON, + "jsonParse": jsonParse, + "isSlice": isSlice, + "isJSONKV": isJSONKV, + "formatJSONValue": formatJSONValue, + "slugify": slugify, + "add": add, +} + +type JSONKV struct { + Key string + Value any +} + +type SortedJSONMap []JSONKV + +func jsonParse(v string) any { + var tmp any + d := json.NewDecoder(strings.NewReader(v)) + d.UseNumber() + if err := d.Decode(&tmp); err != nil { + return nil + } + return toSortedJSON(tmp) +} + +func toSortedJSON(v any) any { + switch v := v.(type) { + case map[string]any: + var out SortedJSONMap + for k, val := range v { + out = append(out, JSONKV{Key: k, Value: toSortedJSON(val)}) + } + return sortJSONKVs(out) + case []any: + out := make([]any, len(v)) + for i, val := range v { + out[i] = toSortedJSON(val) + } + return out + case string: + // Try to parse string as JSON if it looks like an object or array. + str := strings.TrimSpace(v) + if (strings.HasPrefix(str, "{") && strings.HasSuffix(str, "}")) || + (strings.HasPrefix(str, "[") && strings.HasSuffix(str, "]")) { + var tmp any + d := json.NewDecoder(strings.NewReader(str)) + d.UseNumber() + if err := d.Decode(&tmp); err == nil { + return toSortedJSON(tmp) + } + } + return v + } + return v +} + +func sortJSONKVs(kvs SortedJSONMap) SortedJSONMap { + // Simple bubble sort for stability. + for i := 1; i < len(kvs); i++ { + for j := i; j > 0 && kvs[j-1].Key > kvs[j].Key; j-- { + kvs[j], kvs[j-1] = kvs[j-1], kvs[j] + } + } + return kvs +} + +func tryFormatJSON(v string) string { + var out bytes.Buffer + if err := json.Indent(&out, []byte(v), "", " "); err == nil { + return out.String() + } + return v +} + +func isSlice(v any) bool { + return reflect.TypeOf(v).Kind() == reflect.Slice +} + +var slugifyRe = regexp.MustCompile(`[^a-z0-9]+`) + +func slugify(text string) string { + text = strings.ToLower(text) + text = slugifyRe.ReplaceAllString(text, "-") + text = strings.Trim(text, "-") + return text +} + +func isJSONKV(v any) bool { + _, ok := v.(SortedJSONMap) + return ok +} + +func formatJSONValue(v any) template.HTML { + switch val := v.(type) { + case string: + if val == "" { + return template.HTML(`empty`) + } + // If multiline, render as a block. + if strings.Contains(val, "\n") { + return template.HTML(fmt.Sprintf(`
%s
`, template.HTMLEscapeString(val))) + } + return template.HTML(fmt.Sprintf(`%s`, template.HTMLEscapeString(val))) + case json.Number: + return template.HTML(fmt.Sprintf(`%s`, val.String())) + case bool: + return template.HTML(fmt.Sprintf(`%v`, val)) + case nil: + return template.HTML(`null`) + case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64: + return template.HTML(fmt.Sprintf(`%v`, val)) + default: + // Fallback for unknown types. + return template.HTML(template.HTMLEscapeString(fmt.Sprint(val))) + } } func selectBisect(rep *dashapi.BugReport) *dashapi.BisectResult { @@ -219,3 +338,7 @@ func dereferencePointer(v any) any { func commitLink(repo, commit string) string { return vcs.CommitLink(repo, commit) } + +func add(a, b int) int { + return a + b +}