aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/cli/internal/runsummary/run_summary.go
diff options
context:
space:
mode:
Diffstat (limited to 'cli/internal/runsummary/run_summary.go')
-rw-r--r--cli/internal/runsummary/run_summary.go320
1 files changed, 320 insertions, 0 deletions
diff --git a/cli/internal/runsummary/run_summary.go b/cli/internal/runsummary/run_summary.go
new file mode 100644
index 0000000..a297114
--- /dev/null
+++ b/cli/internal/runsummary/run_summary.go
@@ -0,0 +1,320 @@
+// Package runsummary implements structs that report on a `turbo run` and `turbo run --dry`
+package runsummary
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "path/filepath"
+ "sync"
+ "time"
+
+ "github.com/mitchellh/cli"
+ "github.com/segmentio/ksuid"
+ "github.com/vercel/turbo/cli/internal/client"
+ "github.com/vercel/turbo/cli/internal/spinner"
+ "github.com/vercel/turbo/cli/internal/turbopath"
+ "github.com/vercel/turbo/cli/internal/util"
+ "github.com/vercel/turbo/cli/internal/workspace"
+)
+
+// MissingTaskLabel is printed when a package is missing a definition for a task that is supposed to run
+// E.g. if `turbo run build --dry` is run, and package-a doesn't define a `build` script in package.json,
+// the RunSummary will print this, instead of the script (e.g. `next build`).
+const MissingTaskLabel = "<NONEXISTENT>"
+
+// MissingFrameworkLabel is a string to identify when a workspace doesn't detect a framework
+const MissingFrameworkLabel = "<NO FRAMEWORK DETECTED>"
+
+const runSummarySchemaVersion = "0"
+const runsEndpoint = "/v0/spaces/%s/runs"
+const runsPatchEndpoint = "/v0/spaces/%s/runs/%s"
+const tasksEndpoint = "/v0/spaces/%s/runs/%s/tasks"
+
+type runType int
+
+const (
+ runTypeReal runType = iota
+ runTypeDryText
+ runTypeDryJSON
+)
+
+// Meta is a wrapper around the serializable RunSummary, with some extra information
+// about the Run and references to other things that we need.
+type Meta struct {
+ RunSummary *RunSummary
+ ui cli.Ui
+ repoRoot turbopath.AbsoluteSystemPath // used to write run summary
+ repoPath turbopath.RelativeSystemPath
+ singlePackage bool
+ shouldSave bool
+ apiClient *client.APIClient
+ spaceID string
+ runType runType
+ synthesizedCommand string
+}
+
+// RunSummary contains a summary of what happens in the `turbo run` command and why.
+type RunSummary struct {
+ ID ksuid.KSUID `json:"id"`
+ Version string `json:"version"`
+ TurboVersion string `json:"turboVersion"`
+ GlobalHashSummary *GlobalHashSummary `json:"globalCacheInputs"`
+ Packages []string `json:"packages"`
+ EnvMode util.EnvMode `json:"envMode"`
+ ExecutionSummary *executionSummary `json:"execution,omitempty"`
+ Tasks []*TaskSummary `json:"tasks"`
+}
+
+// NewRunSummary returns a RunSummary instance
+func NewRunSummary(
+ startAt time.Time,
+ ui cli.Ui,
+ repoRoot turbopath.AbsoluteSystemPath,
+ repoPath turbopath.RelativeSystemPath,
+ turboVersion string,
+ apiClient *client.APIClient,
+ runOpts util.RunOpts,
+ packages []string,
+ globalEnvMode util.EnvMode,
+ globalHashSummary *GlobalHashSummary,
+ synthesizedCommand string,
+) Meta {
+ singlePackage := runOpts.SinglePackage
+ profile := runOpts.Profile
+ shouldSave := runOpts.Summarize
+ spaceID := runOpts.ExperimentalSpaceID
+
+ runType := runTypeReal
+ if runOpts.DryRun {
+ runType = runTypeDryText
+ if runOpts.DryRunJSON {
+ runType = runTypeDryJSON
+ }
+ }
+
+ executionSummary := newExecutionSummary(synthesizedCommand, repoPath, startAt, profile)
+
+ return Meta{
+ RunSummary: &RunSummary{
+ ID: ksuid.New(),
+ Version: runSummarySchemaVersion,
+ ExecutionSummary: executionSummary,
+ TurboVersion: turboVersion,
+ Packages: packages,
+ EnvMode: globalEnvMode,
+ Tasks: []*TaskSummary{},
+ GlobalHashSummary: globalHashSummary,
+ },
+ ui: ui,
+ runType: runType,
+ repoRoot: repoRoot,
+ singlePackage: singlePackage,
+ shouldSave: shouldSave,
+ apiClient: apiClient,
+ spaceID: spaceID,
+ synthesizedCommand: synthesizedCommand,
+ }
+}
+
+// getPath returns a path to where the runSummary is written.
+// The returned path will always be relative to the dir passsed in.
+// We don't do a lot of validation, so `../../` paths are allowed.
+func (rsm *Meta) getPath() turbopath.AbsoluteSystemPath {
+ filename := fmt.Sprintf("%s.json", rsm.RunSummary.ID)
+ return rsm.repoRoot.UntypedJoin(filepath.Join(".turbo", "runs"), filename)
+}
+
+// Close wraps up the RunSummary at the end of a `turbo run`.
+func (rsm *Meta) Close(ctx context.Context, exitCode int, workspaceInfos workspace.Catalog) error {
+ if rsm.runType == runTypeDryJSON || rsm.runType == runTypeDryText {
+ return rsm.closeDryRun(workspaceInfos)
+ }
+
+ rsm.RunSummary.ExecutionSummary.exitCode = exitCode
+ rsm.RunSummary.ExecutionSummary.endedAt = time.Now()
+
+ summary := rsm.RunSummary
+ if err := writeChrometracing(summary.ExecutionSummary.profileFilename, rsm.ui); err != nil {
+ rsm.ui.Error(fmt.Sprintf("Error writing tracing data: %v", err))
+ }
+
+ // TODO: printing summary to local, writing to disk, and sending to API
+ // are all the same thng, we should use a strategy similar to cache save/upload to
+ // do this in parallel.
+
+ // Otherwise, attempt to save the summary
+ // Warn on the error, but we don't need to throw an error
+ if rsm.shouldSave {
+ if err := rsm.save(); err != nil {
+ rsm.ui.Warn(fmt.Sprintf("Error writing run summary: %v", err))
+ }
+ }
+
+ rsm.printExecutionSummary()
+
+ // If we're not supposed to save or if there's no spaceID
+ if !rsm.shouldSave || rsm.spaceID == "" {
+ return nil
+ }
+
+ if !rsm.apiClient.IsLinked() {
+ rsm.ui.Warn("Failed to post to space because repo is not linked to a Space. Run `turbo link` first.")
+ return nil
+ }
+
+ // Wrap the record function so we can hoist out url/errors but keep
+ // the function signature/type the spinner.WaitFor expects.
+ var url string
+ var errs []error
+ record := func() {
+ url, errs = rsm.record()
+ }
+
+ func() {
+ _ = spinner.WaitFor(ctx, record, rsm.ui, "...sending run summary...", 1000*time.Millisecond)
+ }()
+
+ // After the spinner is done, print any errors and the url
+ if len(errs) > 0 {
+ rsm.ui.Warn("Errors recording run to Spaces")
+ for _, err := range errs {
+ rsm.ui.Warn(fmt.Sprintf("%v", err))
+ }
+ }
+
+ if url != "" {
+ rsm.ui.Output(fmt.Sprintf("Run: %s", url))
+ rsm.ui.Output("")
+ }
+
+ return nil
+}
+
+// closeDryRun wraps up the Run Summary at the end of `turbo run --dry`.
+// Ideally this should be inlined into Close(), but RunSummary doesn't currently
+// have context about whether a run was real or dry.
+func (rsm *Meta) closeDryRun(workspaceInfos workspace.Catalog) error {
+ // Render the dry run as json
+ if rsm.runType == runTypeDryJSON {
+ rendered, err := rsm.FormatJSON()
+ if err != nil {
+ return err
+ }
+
+ rsm.ui.Output(string(rendered))
+ return nil
+ }
+
+ return rsm.FormatAndPrintText(workspaceInfos)
+}
+
+// TrackTask makes it possible for the consumer to send information about the execution of a task.
+func (summary *RunSummary) TrackTask(taskID string) (func(outcome executionEventName, err error, exitCode *int), *TaskExecutionSummary) {
+ return summary.ExecutionSummary.run(taskID)
+}
+
+// Save saves the run summary to a file
+func (rsm *Meta) save() error {
+ json, err := rsm.FormatJSON()
+ if err != nil {
+ return err
+ }
+
+ // summaryPath will always be relative to the dir passsed in.
+ // We don't do a lot of validation, so `../../` paths are allowed
+ summaryPath := rsm.getPath()
+
+ if err := summaryPath.EnsureDir(); err != nil {
+ return err
+ }
+
+ return summaryPath.WriteFile(json, 0644)
+}
+
+// record sends the summary to the API
+func (rsm *Meta) record() (string, []error) {
+ errs := []error{}
+
+ // Right now we'll send the POST to create the Run and the subsequent task payloads
+ // after all execution is done, but in the future, this first POST request
+ // can happen when the Run actually starts, so we can send updates to the associated Space
+ // as tasks complete.
+ createRunEndpoint := fmt.Sprintf(runsEndpoint, rsm.spaceID)
+ response := &spacesRunResponse{}
+
+ payload := rsm.newSpacesRunCreatePayload()
+ if startPayload, err := json.Marshal(payload); err == nil {
+ if resp, err := rsm.apiClient.JSONPost(createRunEndpoint, startPayload); err != nil {
+ errs = append(errs, fmt.Errorf("POST %s: %w", createRunEndpoint, err))
+ } else {
+ if err := json.Unmarshal(resp, response); err != nil {
+ errs = append(errs, fmt.Errorf("Error unmarshaling response: %w", err))
+ }
+ }
+ }
+
+ if response.ID != "" {
+ if taskErrs := rsm.postTaskSummaries(response.ID); len(taskErrs) > 0 {
+ errs = append(errs, taskErrs...)
+ }
+
+ if donePayload, err := json.Marshal(newSpacesDonePayload(rsm.RunSummary)); err == nil {
+ patchURL := fmt.Sprintf(runsPatchEndpoint, rsm.spaceID, response.ID)
+ if _, err := rsm.apiClient.JSONPatch(patchURL, donePayload); err != nil {
+ errs = append(errs, fmt.Errorf("PATCH %s: %w", patchURL, err))
+ }
+ }
+ }
+
+ if len(errs) > 0 {
+ return response.URL, errs
+ }
+
+ return response.URL, nil
+}
+
+func (rsm *Meta) postTaskSummaries(runID string) []error {
+ errs := []error{}
+ // We make at most 8 requests at a time.
+ maxParallelRequests := 8
+ taskSummaries := rsm.RunSummary.Tasks
+ taskCount := len(taskSummaries)
+ taskURL := fmt.Sprintf(tasksEndpoint, rsm.spaceID, runID)
+
+ parallelRequestCount := maxParallelRequests
+ if taskCount < maxParallelRequests {
+ parallelRequestCount = taskCount
+ }
+
+ queue := make(chan int, taskCount)
+
+ wg := &sync.WaitGroup{}
+ for i := 0; i < parallelRequestCount; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for index := range queue {
+ task := taskSummaries[index]
+ payload := newSpacesTaskPayload(task)
+ if taskPayload, err := json.Marshal(payload); err == nil {
+ if _, err := rsm.apiClient.JSONPost(taskURL, taskPayload); err != nil {
+ errs = append(errs, fmt.Errorf("Error sending %s summary to space: %w", task.TaskID, err))
+ }
+ }
+ }
+ }()
+ }
+
+ for index := range taskSummaries {
+ queue <- index
+ }
+ close(queue)
+ wg.Wait()
+
+ if len(errs) > 0 {
+ return errs
+ }
+
+ return nil
+}