Skip to content

Commit e796437

Browse files
authored
feat: Add invocation history command and per-invocation log streaming (#17)
### Summary This PR introduces two new features to enhance invocation management and debugging capabilities in the Kernel CLI: 1. **Invocation History Command** (`kernel invoke history`) - View historical invocations across all apps or filtered by a specific app 2. **Per-Invocation Log Streaming** (`kernel logs <app> --invocation <id>`) - Stream logs for a specific invocation run ### Changes #### New `kernel invoke history` Command - Lists all invocations across all apps by default - Supports filtering by app name using `--app` flag - Configurable result limit with `--limit` flag (default: 100) - Displays invocation details in a formatted table including: - Invocation ID - App Name - Action Name - Status (succeeded/failed/running) - Start time (local timezone) - Duration (with special handling for running invocations) - Output (truncated to 50 characters) **Usage:** ```bash # Show all invocations kernel invoke history # Show invocations for a specific app kernel invoke history --app my-app # Limit results kernel invoke history --limit 20 ``` #### Enhanced Log Streaming - Added `--invocation` flag to `kernel logs` command - Allows streaming logs for a specific invocation by ID or unambiguous prefix - Validates that the invocation belongs to the specified app - Respects `--follow` flag for continuous streaming - Implements a 3-second timeout for non-follow mode **Usage:** ```bash # Stream logs for a specific invocation kernel logs my-app --invocation abc123 # Follow logs for a running invocation kernel logs my-app --invocation abc123 --follow ``` #### Other Improvements - Added invocation ID logging when creating new invocations for easier reference - Updated SDK to v0.11.4 - Fixed SDK API call signatures for `FollowStreaming` method - Improved error handling for streaming events ### Technical Details - Simplified invocation history implementation to use a single API call instead of iterating through apps - Switched from `ListAutoPaging` to `List` to avoid pagination parsing issues - Added proper validation for command arguments in the invoke command ### Testing - Tested invocation history with and without app filtering - Verified per-invocation log streaming for both running and completed invocations - Confirmed proper error handling and validation --- <!-- mesa-description-start --> ## TL;DR Adds `kernel invoke history` to view past invocations and enhances `kernel logs` with an `--invocation` flag to stream logs for a specific run. ## Why we made these changes To improve debugging and observability by allowing users to easily find and view logs for specific invocation runs, instead of searching through an entire app's log stream. ## What changed? - **`cmd/invoke.go`:** - Introduced the `invoke history` subcommand to list and filter recent invocations. - The `invoke` command now prints the invocation ID upon submission for easier reference. - **`cmd/logs.go`:** - Added an `--invocation` (`-i`) flag to stream logs for a specific invocation ID. - Enhanced log streaming to parse and display distinct error events. - **`go.mod` & `go.sum`:** - Bumped the `kernel-go-sdk` dependency from `v0.11.1` to `v0.11.4`. ## Validation - [ ] Tested invocation history with and without app filtering. - [ ] Verified per-invocation log streaming for both running and completed invocations. - [ ] Confirmed proper error handling and validation for new command flags. <sup>_Description generated by Mesa. [Update settings](https://app.mesa.dev/onkernel/settings/pull-requests)_</sup> <!-- mesa-description-end -->
1 parent 278a502 commit e796437

File tree

4 files changed

+185
-7
lines changed

4 files changed

+185
-7
lines changed

cmd/invoke.go

Lines changed: 90 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"syscall"
1313
"time"
1414

15+
"github.com/onkernel/cli/pkg/util"
1516
"github.com/onkernel/kernel-go-sdk"
1617
"github.com/onkernel/kernel-go-sdk/option"
1718
"github.com/pterm/pterm"
@@ -21,17 +22,30 @@ import (
2122
var invokeCmd = &cobra.Command{
2223
Use: "invoke <app_name> <action_name> [flags]",
2324
Short: "Invoke a deployed Kernel application",
24-
Args: cobra.ExactArgs(2),
2525
RunE: runInvoke,
2626
}
2727

28+
var invocationHistoryCmd = &cobra.Command{
29+
Use: "history",
30+
Short: "Show invocation history",
31+
Args: cobra.NoArgs,
32+
RunE: runInvocationHistory,
33+
}
34+
2835
func init() {
2936
invokeCmd.Flags().StringP("version", "v", "latest", "Specify a version of the app to invoke (optional, defaults to 'latest')")
3037
invokeCmd.Flags().StringP("payload", "p", "", "JSON payload for the invocation (optional)")
3138
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.")
39+
40+
invocationHistoryCmd.Flags().Int("limit", 100, "Max invocations to return (default 100)")
41+
invocationHistoryCmd.Flags().StringP("app", "a", "", "Filter by app name")
42+
invokeCmd.AddCommand(invocationHistoryCmd)
3243
}
3344

3445
func runInvoke(cmd *cobra.Command, args []string) error {
46+
if len(args) != 2 {
47+
return fmt.Errorf("requires exactly 2 arguments: <app_name> <action_name>")
48+
}
3549
startTime := time.Now()
3650
client := getKernelClient(cmd)
3751
appName := args[0]
@@ -70,6 +84,8 @@ func runInvoke(cmd *cobra.Command, args []string) error {
7084
if err != nil {
7185
return handleSdkError(err)
7286
}
87+
// Log the invocation ID for user reference
88+
pterm.Info.Printfln("Invocation ID: %s", resp.ID)
7389
// coordinate the cleanup with the polling loop to ensure this is given enough time to run
7490
// before this function returns
7591
cleanupDone := make(chan struct{})
@@ -117,7 +133,7 @@ func runInvoke(cmd *cobra.Command, args []string) error {
117133
})
118134

119135
// Start following events
120-
stream := client.Invocations.FollowStreaming(cmd.Context(), resp.ID, option.WithMaxRetries(0))
136+
stream := client.Invocations.FollowStreaming(cmd.Context(), resp.ID, kernel.InvocationFollowParams{}, option.WithMaxRetries(0))
121137
for stream.Next() {
122138
ev := stream.Current()
123139

@@ -187,3 +203,75 @@ func printResult(success bool, output string) {
187203
pterm.Error.Printf("Result:\n%s\n", output)
188204
}
189205
}
206+
207+
func runInvocationHistory(cmd *cobra.Command, args []string) error {
208+
client := getKernelClient(cmd)
209+
210+
lim, _ := cmd.Flags().GetInt("limit")
211+
appFilter, _ := cmd.Flags().GetString("app")
212+
213+
// Build parameters for the API call
214+
params := kernel.InvocationListParams{
215+
Limit: kernel.Opt(int64(lim)),
216+
}
217+
218+
// Only add app filter if specified
219+
if appFilter != "" {
220+
params.AppName = kernel.Opt(appFilter)
221+
pterm.Debug.Printf("Listing invocations for app '%s'...\n", appFilter)
222+
} else {
223+
pterm.Debug.Printf("Listing all invocations...\n")
224+
}
225+
226+
// Make a single API call to get invocations
227+
invocations, err := client.Invocations.List(cmd.Context(), params)
228+
if err != nil {
229+
pterm.Error.Printf("Failed to list invocations: %v\n", err)
230+
return nil
231+
}
232+
233+
table := pterm.TableData{{"Invocation ID", "App Name", "Action", "Status", "Started At", "Duration", "Output"}}
234+
235+
for _, inv := range invocations.Items {
236+
started := util.FormatLocal(inv.StartedAt)
237+
status := string(inv.Status)
238+
239+
// Calculate duration
240+
var duration string
241+
if !inv.FinishedAt.IsZero() {
242+
dur := inv.FinishedAt.Sub(inv.StartedAt)
243+
duration = dur.Round(time.Millisecond).String()
244+
} else if status == "running" {
245+
dur := time.Since(inv.StartedAt)
246+
duration = dur.Round(time.Second).String() + " (running)"
247+
} else {
248+
duration = "-"
249+
}
250+
251+
// Truncate output for display
252+
output := inv.Output
253+
if len(output) > 50 {
254+
output = output[:47] + "..."
255+
}
256+
if output == "" {
257+
output = "-"
258+
}
259+
260+
table = append(table, []string{
261+
inv.ID,
262+
inv.AppName,
263+
inv.ActionName,
264+
status,
265+
started,
266+
duration,
267+
output,
268+
})
269+
}
270+
271+
if len(table) == 1 {
272+
pterm.Info.Println("No invocations found.")
273+
} else {
274+
pterm.DefaultTable.WithHasHeader().WithData(table).Render()
275+
}
276+
return nil
277+
}

cmd/logs.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ func init() {
2323
logsCmd.Flags().BoolP("follow", "f", false, "Follow logs in real-time (stream continuously)")
2424
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.")
2525
logsCmd.Flags().Bool("with-timestamps", false, "Include timestamps in each log line")
26+
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.")
2627
rootCmd.AddCommand(logsCmd)
2728
}
2829

@@ -34,6 +35,7 @@ func runLogs(cmd *cobra.Command, args []string) error {
3435
follow, _ := cmd.Flags().GetBool("follow")
3536
since, _ := cmd.Flags().GetString("since")
3637
timestamps, _ := cmd.Flags().GetBool("with-timestamps")
38+
invocationRef, _ := cmd.Flags().GetString("invocation")
3739
if version == "" {
3840
version = "latest"
3941
}
@@ -45,6 +47,90 @@ func runLogs(cmd *cobra.Command, args []string) error {
4547
}
4648
}
4749

50+
// If an invocation is specified, stream invocation-specific logs and return
51+
if invocationRef != "" {
52+
inv, err := client.Invocations.Get(cmd.Context(), invocationRef)
53+
if err != nil {
54+
return fmt.Errorf("failed to get invocation: %w", err)
55+
}
56+
if inv.AppName != appName {
57+
return fmt.Errorf("invocation %s does not belong to app \"%s\" (found app: %s)", inv.ID, appName, inv.AppName)
58+
}
59+
60+
pterm.Info.Printf("Streaming logs for invocation \"%s\" of app \"%s\" (action: %s, status: %s)...\n", inv.ID, inv.AppName, inv.ActionName, inv.Status)
61+
if follow {
62+
pterm.Info.Println("Press Ctrl+C to exit")
63+
} else {
64+
pterm.Info.Println("Showing recent logs (timeout after 3s with no events)")
65+
}
66+
67+
stream := client.Invocations.FollowStreaming(cmd.Context(), inv.ID, kernel.InvocationFollowParams{}, option.WithMaxRetries(0))
68+
if stream.Err() != nil {
69+
return fmt.Errorf("failed to follow streaming: %w", stream.Err())
70+
}
71+
72+
if follow {
73+
for stream.Next() {
74+
data := stream.Current()
75+
switch data.Event {
76+
case "log":
77+
logEntry := data.AsLog()
78+
if timestamps {
79+
fmt.Printf("%s %s\n", util.FormatLocal(logEntry.Timestamp), logEntry.Message)
80+
} else {
81+
fmt.Println(logEntry.Message)
82+
}
83+
case "error":
84+
errEv := data.AsError()
85+
pterm.Error.Printfln("%s: %s", errEv.Error.Code, errEv.Error.Message)
86+
}
87+
}
88+
} else {
89+
timeout := time.NewTimer(3 * time.Second)
90+
defer timeout.Stop()
91+
92+
done := false
93+
for !done {
94+
nextCh := make(chan bool, 1)
95+
go func() {
96+
hasNext := stream.Next()
97+
nextCh <- hasNext
98+
}()
99+
100+
select {
101+
case hasNext := <-nextCh:
102+
if !hasNext {
103+
done = true
104+
} else {
105+
data := stream.Current()
106+
switch data.Event {
107+
case "log":
108+
logEntry := data.AsLog()
109+
if timestamps {
110+
fmt.Printf("%s %s\n", util.FormatLocal(logEntry.Timestamp), logEntry.Message)
111+
} else {
112+
fmt.Println(logEntry.Message)
113+
}
114+
case "error":
115+
errEv := data.AsError()
116+
pterm.Error.Printfln("%s: %s", errEv.Error.Code, errEv.Error.Message)
117+
}
118+
timeout.Reset(3 * time.Second)
119+
}
120+
case <-timeout.C:
121+
done = true
122+
stream.Close()
123+
return nil
124+
}
125+
}
126+
}
127+
128+
if stream.Err() != nil {
129+
return fmt.Errorf("failed to follow streaming: %w", stream.Err())
130+
}
131+
return nil
132+
}
133+
48134
params := kernel.AppListParams{
49135
AppName: kernel.Opt(appName),
50136
Version: kernel.Opt(version),
@@ -88,6 +174,9 @@ func runLogs(cmd *cobra.Command, args []string) error {
88174
} else {
89175
fmt.Println(logEntry.Message)
90176
}
177+
case "error":
178+
errEv := data.AsErrorEvent()
179+
pterm.Error.Printfln("%s: %s", errEv.Error.Code, errEv.Error.Message)
91180
}
92181
}
93182
} else {
@@ -122,6 +211,9 @@ func runLogs(cmd *cobra.Command, args []string) error {
122211
} else {
123212
fmt.Println(logEntry.Message)
124213
}
214+
case "error":
215+
errEv := data.AsErrorEvent()
216+
pterm.Error.Printfln("%s: %s", errEv.Error.Code, errEv.Error.Message)
125217
}
126218
timeout.Reset(3 * time.Second)
127219
}

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ require (
88
github.com/charmbracelet/fang v0.2.0
99
github.com/golang-jwt/jwt/v5 v5.2.2
1010
github.com/joho/godotenv v1.5.1
11-
github.com/onkernel/kernel-go-sdk v0.11.1
11+
github.com/onkernel/kernel-go-sdk v0.11.4
1212
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
1313
github.com/pterm/pterm v0.12.80
1414
github.com/samber/lo v1.51.0

go.sum

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -91,10 +91,8 @@ github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe
9191
github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0=
9292
github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
9393
github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
94-
github.com/onkernel/kernel-go-sdk v0.11.0 h1:7KUKHiz5t4jdnNCwA8NM1dTtEYdk/AV/RIe8T/HjJwg=
95-
github.com/onkernel/kernel-go-sdk v0.11.0/go.mod h1:q7wsAf+yjpY+w8jbAMciWCtCM0ZUxiw/5o2MSPTZS9E=
96-
github.com/onkernel/kernel-go-sdk v0.11.1 h1:gTxhXtsXrJcrM7KEobEVXa8mPPtRFMlxQwNqkyoCrDI=
97-
github.com/onkernel/kernel-go-sdk v0.11.1/go.mod h1:q7wsAf+yjpY+w8jbAMciWCtCM0ZUxiw/5o2MSPTZS9E=
94+
github.com/onkernel/kernel-go-sdk v0.11.4 h1:vgDcPtldfEcRh+a1wlOSOY2bBWjxLFUwHqeXHHQ4OjM=
95+
github.com/onkernel/kernel-go-sdk v0.11.4/go.mod h1:MjUR92i8UPqjrmneyVykae6GuB3GGSmnQtnjf1v74Dc=
9896
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
9997
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
10098
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=

0 commit comments

Comments
 (0)