diff --git a/cmd/invoke.go b/cmd/invoke.go index 17cd032..2af7e5f 100644 --- a/cmd/invoke.go +++ b/cmd/invoke.go @@ -12,6 +12,7 @@ import ( "syscall" "time" + "github.com/onkernel/cli/pkg/util" "github.com/onkernel/kernel-go-sdk" "github.com/onkernel/kernel-go-sdk/option" "github.com/pterm/pterm" @@ -21,17 +22,30 @@ import ( var invokeCmd = &cobra.Command{ Use: "invoke [flags]", Short: "Invoke a deployed Kernel application", - Args: cobra.ExactArgs(2), RunE: runInvoke, } +var invocationHistoryCmd = &cobra.Command{ + Use: "history", + Short: "Show invocation history", + Args: cobra.NoArgs, + RunE: runInvocationHistory, +} + func init() { invokeCmd.Flags().StringP("version", "v", "latest", "Specify a version of the app to invoke (optional, defaults to 'latest')") invokeCmd.Flags().StringP("payload", "p", "", "JSON payload for the invocation (optional)") invokeCmd.Flags().BoolP("sync", "s", false, "Invoke synchronously (default false). A synchronous invocation will open a long-lived HTTP POST to the Kernel API to wait for the invocation to complete. This will time out after 60 seconds, so only use this option if you expect your invocation to complete in less than 60 seconds. The default is to invoke asynchronously, in which case the CLI will open an SSE connection to the Kernel API after submitting the invocation and wait for the invocation to complete.") + + invocationHistoryCmd.Flags().Int("limit", 100, "Max invocations to return (default 100)") + invocationHistoryCmd.Flags().StringP("app", "a", "", "Filter by app name") + invokeCmd.AddCommand(invocationHistoryCmd) } func runInvoke(cmd *cobra.Command, args []string) error { + if len(args) != 2 { + return fmt.Errorf("requires exactly 2 arguments: ") + } startTime := time.Now() client := getKernelClient(cmd) appName := args[0] @@ -70,6 +84,8 @@ func runInvoke(cmd *cobra.Command, args []string) error { if err != nil { return handleSdkError(err) } + // Log the invocation ID for user reference + pterm.Info.Printfln("Invocation ID: %s", resp.ID) // coordinate the cleanup with the polling loop to ensure this is given enough time to run // before this function returns cleanupDone := make(chan struct{}) @@ -117,7 +133,7 @@ func runInvoke(cmd *cobra.Command, args []string) error { }) // Start following events - stream := client.Invocations.FollowStreaming(cmd.Context(), resp.ID, option.WithMaxRetries(0)) + stream := client.Invocations.FollowStreaming(cmd.Context(), resp.ID, kernel.InvocationFollowParams{}, option.WithMaxRetries(0)) for stream.Next() { ev := stream.Current() @@ -187,3 +203,75 @@ func printResult(success bool, output string) { pterm.Error.Printf("Result:\n%s\n", output) } } + +func runInvocationHistory(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + + lim, _ := cmd.Flags().GetInt("limit") + appFilter, _ := cmd.Flags().GetString("app") + + // Build parameters for the API call + params := kernel.InvocationListParams{ + Limit: kernel.Opt(int64(lim)), + } + + // Only add app filter if specified + if appFilter != "" { + params.AppName = kernel.Opt(appFilter) + pterm.Debug.Printf("Listing invocations for app '%s'...\n", appFilter) + } else { + pterm.Debug.Printf("Listing all invocations...\n") + } + + // Make a single API call to get invocations + invocations, err := client.Invocations.List(cmd.Context(), params) + if err != nil { + pterm.Error.Printf("Failed to list invocations: %v\n", err) + return nil + } + + table := pterm.TableData{{"Invocation ID", "App Name", "Action", "Status", "Started At", "Duration", "Output"}} + + for _, inv := range invocations.Items { + started := util.FormatLocal(inv.StartedAt) + status := string(inv.Status) + + // Calculate duration + var duration string + if !inv.FinishedAt.IsZero() { + dur := inv.FinishedAt.Sub(inv.StartedAt) + duration = dur.Round(time.Millisecond).String() + } else if status == "running" { + dur := time.Since(inv.StartedAt) + duration = dur.Round(time.Second).String() + " (running)" + } else { + duration = "-" + } + + // Truncate output for display + output := inv.Output + if len(output) > 50 { + output = output[:47] + "..." + } + if output == "" { + output = "-" + } + + table = append(table, []string{ + inv.ID, + inv.AppName, + inv.ActionName, + status, + started, + duration, + output, + }) + } + + if len(table) == 1 { + pterm.Info.Println("No invocations found.") + } else { + pterm.DefaultTable.WithHasHeader().WithData(table).Render() + } + return nil +} diff --git a/cmd/logs.go b/cmd/logs.go index bd1d083..80f4164 100644 --- a/cmd/logs.go +++ b/cmd/logs.go @@ -23,6 +23,7 @@ func init() { logsCmd.Flags().BoolP("follow", "f", false, "Follow logs in real-time (stream continuously)") logsCmd.Flags().String("since", "s", "How far back to retrieve logs. Supports duration formats: ns, us, ms, s, m, h (e.g., 5m, 2h, 1h30m). Note: 'd' for days is NOT supported - use hours instead. Can also specify timestamps: 2006-01-02 (day), 2006-01-02T15:04 (minute), 2006-01-02T15:04:05 (second), 2006-01-02T15:04:05.000 (ms). Maximum lookback is 167h (just under 7 days). Defaults to 5m if not following, 5s if following.") logsCmd.Flags().Bool("with-timestamps", false, "Include timestamps in each log line") + logsCmd.Flags().StringP("invocation", "i", "", "Show logs for a specific invocation/run of the app. Accepts full ID or unambiguous prefix. If the invocation is still running, streaming respects --follow.") rootCmd.AddCommand(logsCmd) } @@ -34,6 +35,7 @@ func runLogs(cmd *cobra.Command, args []string) error { follow, _ := cmd.Flags().GetBool("follow") since, _ := cmd.Flags().GetString("since") timestamps, _ := cmd.Flags().GetBool("with-timestamps") + invocationRef, _ := cmd.Flags().GetString("invocation") if version == "" { version = "latest" } @@ -45,6 +47,90 @@ func runLogs(cmd *cobra.Command, args []string) error { } } + // If an invocation is specified, stream invocation-specific logs and return + if invocationRef != "" { + inv, err := client.Invocations.Get(cmd.Context(), invocationRef) + if err != nil { + return fmt.Errorf("failed to get invocation: %w", err) + } + if inv.AppName != appName { + return fmt.Errorf("invocation %s does not belong to app \"%s\" (found app: %s)", inv.ID, appName, inv.AppName) + } + + pterm.Info.Printf("Streaming logs for invocation \"%s\" of app \"%s\" (action: %s, status: %s)...\n", inv.ID, inv.AppName, inv.ActionName, inv.Status) + if follow { + pterm.Info.Println("Press Ctrl+C to exit") + } else { + pterm.Info.Println("Showing recent logs (timeout after 3s with no events)") + } + + stream := client.Invocations.FollowStreaming(cmd.Context(), inv.ID, kernel.InvocationFollowParams{}, option.WithMaxRetries(0)) + if stream.Err() != nil { + return fmt.Errorf("failed to follow streaming: %w", stream.Err()) + } + + if follow { + for stream.Next() { + data := stream.Current() + switch data.Event { + case "log": + logEntry := data.AsLog() + if timestamps { + fmt.Printf("%s %s\n", util.FormatLocal(logEntry.Timestamp), logEntry.Message) + } else { + fmt.Println(logEntry.Message) + } + case "error": + errEv := data.AsError() + pterm.Error.Printfln("%s: %s", errEv.Error.Code, errEv.Error.Message) + } + } + } else { + timeout := time.NewTimer(3 * time.Second) + defer timeout.Stop() + + done := false + for !done { + nextCh := make(chan bool, 1) + go func() { + hasNext := stream.Next() + nextCh <- hasNext + }() + + select { + case hasNext := <-nextCh: + if !hasNext { + done = true + } else { + data := stream.Current() + switch data.Event { + case "log": + logEntry := data.AsLog() + if timestamps { + fmt.Printf("%s %s\n", util.FormatLocal(logEntry.Timestamp), logEntry.Message) + } else { + fmt.Println(logEntry.Message) + } + case "error": + errEv := data.AsError() + pterm.Error.Printfln("%s: %s", errEv.Error.Code, errEv.Error.Message) + } + timeout.Reset(3 * time.Second) + } + case <-timeout.C: + done = true + stream.Close() + return nil + } + } + } + + if stream.Err() != nil { + return fmt.Errorf("failed to follow streaming: %w", stream.Err()) + } + return nil + } + params := kernel.AppListParams{ AppName: kernel.Opt(appName), Version: kernel.Opt(version), @@ -88,6 +174,9 @@ func runLogs(cmd *cobra.Command, args []string) error { } else { fmt.Println(logEntry.Message) } + case "error": + errEv := data.AsErrorEvent() + pterm.Error.Printfln("%s: %s", errEv.Error.Code, errEv.Error.Message) } } } else { @@ -122,6 +211,9 @@ func runLogs(cmd *cobra.Command, args []string) error { } else { fmt.Println(logEntry.Message) } + case "error": + errEv := data.AsErrorEvent() + pterm.Error.Printfln("%s: %s", errEv.Error.Code, errEv.Error.Message) } timeout.Reset(3 * time.Second) } diff --git a/go.mod b/go.mod index 3032472..55f133c 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/charmbracelet/fang v0.2.0 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/joho/godotenv v1.5.1 - github.com/onkernel/kernel-go-sdk v0.11.1 + github.com/onkernel/kernel-go-sdk v0.11.4 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pterm/pterm v0.12.80 github.com/samber/lo v1.51.0 diff --git a/go.sum b/go.sum index f9360bc..9ba756e 100644 --- a/go.sum +++ b/go.sum @@ -91,10 +91,8 @@ github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0= github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= -github.com/onkernel/kernel-go-sdk v0.11.0 h1:7KUKHiz5t4jdnNCwA8NM1dTtEYdk/AV/RIe8T/HjJwg= -github.com/onkernel/kernel-go-sdk v0.11.0/go.mod h1:q7wsAf+yjpY+w8jbAMciWCtCM0ZUxiw/5o2MSPTZS9E= -github.com/onkernel/kernel-go-sdk v0.11.1 h1:gTxhXtsXrJcrM7KEobEVXa8mPPtRFMlxQwNqkyoCrDI= -github.com/onkernel/kernel-go-sdk v0.11.1/go.mod h1:q7wsAf+yjpY+w8jbAMciWCtCM0ZUxiw/5o2MSPTZS9E= +github.com/onkernel/kernel-go-sdk v0.11.4 h1:vgDcPtldfEcRh+a1wlOSOY2bBWjxLFUwHqeXHHQ4OjM= +github.com/onkernel/kernel-go-sdk v0.11.4/go.mod h1:MjUR92i8UPqjrmneyVykae6GuB3GGSmnQtnjf1v74Dc= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=