From 91bf6e790b04d0b8ab67ee4c48b9541a4045b295 Mon Sep 17 00:00:00 2001 From: Emmanuel Jacquier Date: Sun, 31 May 2026 09:36:08 -0400 Subject: [PATCH 01/14] Add cre workflow execution subcommands (list, status, events, logs) --- cmd/root.go | 9 +- cmd/workflow/execution/events.go | 108 +++++ cmd/workflow/execution/execution.go | 23 + cmd/workflow/execution/execution_test.go | 435 ++++++++++++++++++ cmd/workflow/execution/list.go | 276 +++++++++++ cmd/workflow/execution/logs.go | 95 ++++ cmd/workflow/execution/status.go | 86 ++++ cmd/workflow/workflow.go | 2 + .../client/workflowdataclient/execution.go | 386 ++++++++++++++++ .../workflowdataclient/workflowdataclient.go | 8 +- internal/workflowrender/execution.go | 298 ++++++++++++ 11 files changed, 1723 insertions(+), 3 deletions(-) create mode 100644 cmd/workflow/execution/events.go create mode 100644 cmd/workflow/execution/execution.go create mode 100644 cmd/workflow/execution/execution_test.go create mode 100644 cmd/workflow/execution/list.go create mode 100644 cmd/workflow/execution/logs.go create mode 100644 cmd/workflow/execution/status.go create mode 100644 internal/client/workflowdataclient/execution.go create mode 100644 internal/workflowrender/execution.go diff --git a/cmd/root.go b/cmd/root.go index aa149602..b4d30214 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -536,8 +536,13 @@ func isLoadSettings(cmd *cobra.Command) bool { "cre workflow limits": {}, "cre workflow limits export": {}, "cre workflow build": {}, - "cre workflow list": {}, - "cre account": {}, + "cre workflow list": {}, + "cre workflow execution": {}, + "cre workflow execution list": {}, + "cre workflow execution status": {}, + "cre workflow execution events": {}, + "cre workflow execution logs": {}, + "cre account": {}, "cre secrets": {}, "cre templates": {}, "cre templates list": {}, diff --git a/cmd/workflow/execution/events.go b/cmd/workflow/execution/events.go new file mode 100644 index 00000000..de512d9e --- /dev/null +++ b/cmd/workflow/execution/events.go @@ -0,0 +1,108 @@ +package execution + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/smartcontractkit/cre-cli/internal/client/graphqlclient" + "github.com/smartcontractkit/cre-cli/internal/client/workflowdataclient" + "github.com/smartcontractkit/cre-cli/internal/credentials" + "github.com/smartcontractkit/cre-cli/internal/runtime" + "github.com/smartcontractkit/cre-cli/internal/ui" + "github.com/smartcontractkit/cre-cli/internal/workflowrender" +) + +type EventsInputs struct { + ExecutionUUID string + CapabilityID *string + Status *string + OutputFormat string +} + +type EventsHandler struct { + credentials *credentials.Credentials + wdc *workflowdataclient.Client +} + +func NewEventsHandler(ctx *runtime.Context) *EventsHandler { + gql := graphqlclient.New(ctx.Credentials, ctx.EnvironmentSet, ctx.Logger) + wdc := workflowdataclient.New(gql, ctx.Logger) + return &EventsHandler{credentials: ctx.Credentials, wdc: wdc} +} + +func NewEventsHandlerWithClient(ctx *runtime.Context, wdc *workflowdataclient.Client) *EventsHandler { + return &EventsHandler{credentials: ctx.Credentials, wdc: wdc} +} + +func resolveEventsInputs(executionUUID, capabilityID, status, outputFormat string) (EventsInputs, error) { + if outputFormat != "" && outputFormat != outputFormatJSON { + return EventsInputs{}, fmt.Errorf("--output %q is not supported; only %q is accepted", outputFormat, outputFormatJSON) + } + in := EventsInputs{ + ExecutionUUID: executionUUID, + OutputFormat: outputFormat, + } + if capabilityID != "" { + in.CapabilityID = &capabilityID + } + if status != "" { + in.Status = &status + } + return in, nil +} + +func (h *EventsHandler) Execute(ctx context.Context, in EventsInputs) error { + if h.credentials == nil { + return fmt.Errorf("credentials not available — run `cre login` and retry") + } + + spinner := ui.NewSpinner() + spinner.Start("Fetching execution events...") + events, err := h.wdc.ListExecutionEvents(ctx, workflowdataclient.ListEventsInput{ + ExecutionUUID: in.ExecutionUUID, + CapabilityID: in.CapabilityID, + Status: in.Status, + }) + spinner.Stop() + if err != nil { + return err + } + + if in.OutputFormat == outputFormatJSON { + return workflowrender.PrintEventsJSON(events) + } + workflowrender.PrintEventsTable(events) + return nil +} + +func newEvents(runtimeContext *runtime.Context) *cobra.Command { + var capabilityID string + var status string + var outputFormat string + + cmd := &cobra.Command{ + Use: "events ", + Short: "Show the node/capability event timeline for an execution", + Long: `Fetch and display the ordered sequence of capability events for a workflow +execution, including per-event status, method, duration, and any errors.`, + Example: "cre workflow execution events 7f3d8a12-b1c2-4d3e-9f0a-1b2c3d4e5f6g\n" + + " cre workflow execution events 7f3d8a12-b1c2-4d3e-9f0a-1b2c3d4e5f6g --capability fetch-price\n" + + " cre workflow execution events 7f3d8a12-b1c2-4d3e-9f0a-1b2c3d4e5f6g --status FAILURE\n" + + " cre workflow execution events 7f3d8a12-b1c2-4d3e-9f0a-1b2c3d4e5f6g --output json", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + in, err := resolveEventsInputs(args[0], capabilityID, status, outputFormat) + if err != nil { + return err + } + return NewEventsHandler(runtimeContext).Execute(cmd.Context(), in) + }, + } + + cmd.Flags().StringVar(&capabilityID, "capability", "", "Filter events to a specific capability ID") + cmd.Flags().StringVar(&status, "status", "", "Filter events by status (e.g. FAILURE)") + cmd.Flags().StringVar(&outputFormat, "output", "", `Output format: "json" prints a JSON array to stdout`) + return cmd +} diff --git a/cmd/workflow/execution/execution.go b/cmd/workflow/execution/execution.go new file mode 100644 index 00000000..5baee559 --- /dev/null +++ b/cmd/workflow/execution/execution.go @@ -0,0 +1,23 @@ +package execution + +import ( + "github.com/spf13/cobra" + + "github.com/smartcontractkit/cre-cli/internal/runtime" +) + +// New returns the `execution` subcommand group wired under `cre workflow`. +func New(runtimeContext *runtime.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "execution", + Short: "Query workflow execution history", + Long: `The execution command provides visibility into workflow executions, node events, and logs.`, + } + + cmd.AddCommand(newList(runtimeContext)) + cmd.AddCommand(newStatus(runtimeContext)) + cmd.AddCommand(newEvents(runtimeContext)) + cmd.AddCommand(newLogs(runtimeContext)) + + return cmd +} diff --git a/cmd/workflow/execution/execution_test.go b/cmd/workflow/execution/execution_test.go new file mode 100644 index 00000000..3622868b --- /dev/null +++ b/cmd/workflow/execution/execution_test.go @@ -0,0 +1,435 @@ +package execution_test + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/cre-cli/cmd/workflow/execution" + "github.com/smartcontractkit/cre-cli/internal/client/graphqlclient" + "github.com/smartcontractkit/cre-cli/internal/client/workflowdataclient" + "github.com/smartcontractkit/cre-cli/internal/credentials" + "github.com/smartcontractkit/cre-cli/internal/environments" + "github.com/smartcontractkit/cre-cli/internal/runtime" +) + +// ---- helpers ---- + +func nopLogger() *zerolog.Logger { l := zerolog.Nop(); return &l } + +func credsAndEnv(serverURL string) (*credentials.Credentials, *environments.EnvironmentSet) { + creds := &credentials.Credentials{AuthType: credentials.AuthTypeApiKey, APIKey: "test-key"} + env := &environments.EnvironmentSet{GraphQLURL: serverURL} + return creds, env +} + +func wdcFor(t *testing.T, serverURL string) *workflowdataclient.Client { + t.Helper() + creds, env := credsAndEnv(serverURL) + gql := graphqlclient.New(creds, env, nopLogger()) + return workflowdataclient.New(gql, nopLogger()) +} + +func rtCtxFor(t *testing.T, serverURL string) *runtime.Context { + t.Helper() + creds, env := credsAndEnv(serverURL) + return &runtime.Context{ + Logger: nopLogger(), + Credentials: creds, + EnvironmentSet: env, + } +} + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + r, w, err := os.Pipe() + require.NoError(t, err) + old := os.Stdout + os.Stdout = w + fn() + w.Close() + os.Stdout = old + var buf strings.Builder + _, _ = io.Copy(&buf, r) + return buf.String() +} + +// gqlRespond writes a standard GraphQL data envelope. +func gqlRespond(w http.ResponseWriter, payload any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"data": payload}) +} + +// ---- newList tests ---- + +func TestList_MissingCredentials(t *testing.T) { + t.Parallel() + ctx := &runtime.Context{Logger: nopLogger()} + h := execution.NewListHandlerWithClient(ctx, wdcFor(t, "http://unused")) + err := h.Execute(context.Background(), execution.ListInputs{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "credentials not available") +} + +func TestList_InvalidStatus(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gqlRespond(w, map[string]any{ + "workflows": map[string]any{"data": []any{}, "count": 0}, + }) + })) + t.Cleanup(srv.Close) + + cmd := execution.New(rtCtxFor(t, srv.URL)) + cmd.SetArgs([]string{"list", "--status", "RUNNING"}) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "RUNNING") + assert.Contains(t, err.Error(), "not valid") +} + +func TestList_ByUUID_JSON(t *testing.T) { + + started := time.Date(2026, 5, 29, 14, 0, 5, 0, time.UTC) + finished := time.Date(2026, 5, 29, 14, 0, 17, 0, time.UTC) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gqlRespond(w, map[string]any{ + "workflowExecutions": map[string]any{ + "count": 1, + "data": []any{ + map[string]any{ + "uuid": "exec-uuid-1", + "workflowUUID": "wf-uuid-1", + "workflowName": "my-workflow", + "status": "FAILURE", + "startedAt": started.Format(time.RFC3339), + "finishedAt": finished.Format(time.RFC3339), + "creditUsed": "0.05", + "errors": []any{}, + }, + }, + }, + }) + })) + t.Cleanup(srv.Close) + + rtCtx := rtCtxFor(t, srv.URL) + h := execution.NewListHandlerWithClient(rtCtx, wdcFor(t, srv.URL)) + + wfUUID := "wf-uuid-1" + out := captureStdout(t, func() { + err := h.Execute(context.Background(), execution.ListInputs{ + WorkflowUUID: &wfUUID, + OutputFormat: "json", + }) + require.NoError(t, err) + }) + + var result []map[string]any + require.NoError(t, json.Unmarshal([]byte(out), &result)) + require.Len(t, result, 1) + assert.Equal(t, "exec-uuid-1", result[0]["uuid"]) + assert.Equal(t, "FAILURE", result[0]["status"]) + assert.Equal(t, "0.05", result[0]["creditUsed"]) +} + +func TestList_ByName_ResolvesActiveWorkflow(t *testing.T) { + + started := time.Date(2026, 5, 29, 14, 0, 5, 0, time.UTC) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + query, _ := body["query"].(string) + + if strings.Contains(query, "ListWorkflows") { + gqlRespond(w, map[string]any{ + "workflows": map[string]any{ + "count": 1, + "data": []any{ + map[string]any{ + "name": "my-workflow", + "workflowId": "wf-uuid-active", + "ownerAddress": "0xowner", + "status": "ACTIVE", + "workflowSource": "private", + }, + }, + }, + }) + return + } + + gqlRespond(w, map[string]any{ + "workflowExecutions": map[string]any{ + "count": 1, + "data": []any{ + map[string]any{ + "uuid": "exec-uuid-1", + "workflowUUID": "wf-uuid-active", + "workflowName": "my-workflow", + "status": "SUCCESS", + "startedAt": started.Format(time.RFC3339), + "errors": []any{}, + }, + }, + }, + }) + })) + t.Cleanup(srv.Close) + + rtCtx := rtCtxFor(t, srv.URL) + h := execution.NewListHandlerWithClient(rtCtx, wdcFor(t, srv.URL)) + + out := captureStdout(t, func() { + err := h.ExecuteWithArg(context.Background(), "my-workflow", execution.ListInputs{OutputFormat: "json"}) + require.NoError(t, err) + }) + + var result []map[string]any + require.NoError(t, json.Unmarshal([]byte(out), &result)) + require.Len(t, result, 1) + assert.Equal(t, "wf-uuid-active", result[0]["workflowUUID"]) +} + +func TestList_NoArg_ListsAll(t *testing.T) { + + started := time.Date(2026, 5, 29, 14, 0, 5, 0, time.UTC) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gqlRespond(w, map[string]any{ + "workflowExecutions": map[string]any{ + "count": 2, + "data": []any{ + map[string]any{ + "uuid": "exec-1", "workflowUUID": "wf-1", "workflowName": "alpha", + "status": "SUCCESS", "startedAt": started.Format(time.RFC3339), "errors": []any{}, + }, + map[string]any{ + "uuid": "exec-2", "workflowUUID": "wf-2", "workflowName": "beta", + "status": "FAILURE", "startedAt": started.Format(time.RFC3339), "errors": []any{}, + }, + }, + }, + }) + })) + t.Cleanup(srv.Close) + + rtCtx := rtCtxFor(t, srv.URL) + h := execution.NewListHandlerWithClient(rtCtx, wdcFor(t, srv.URL)) + + out := captureStdout(t, func() { + err := h.Execute(context.Background(), execution.ListInputs{OutputFormat: "json"}) + require.NoError(t, err) + }) + + var result []map[string]any + require.NoError(t, json.Unmarshal([]byte(out), &result)) + assert.Len(t, result, 2) +} + +// ---- newStatus tests ---- + +func TestStatus_MissingCredentials(t *testing.T) { + t.Parallel() + ctx := &runtime.Context{Logger: nopLogger()} + h := execution.NewStatusHandlerWithClient(ctx, wdcFor(t, "http://unused")) + err := h.Execute(context.Background(), execution.StatusInputs{ExecutionUUID: "x"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "credentials not available") +} + +func TestStatus_NotFound(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gqlRespond(w, map[string]any{ + "workflowExecution": map[string]any{"data": nil}, + }) + })) + t.Cleanup(srv.Close) + + rtCtx := rtCtxFor(t, srv.URL) + h := execution.NewStatusHandlerWithClient(rtCtx, wdcFor(t, srv.URL)) + err := h.Execute(context.Background(), execution.StatusInputs{ExecutionUUID: "missing-uuid"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +func TestStatus_FailureShowsErrors(t *testing.T) { + + started := time.Date(2026, 5, 29, 14, 0, 5, 0, time.UTC) + finished := time.Date(2026, 5, 29, 14, 0, 17, 0, time.UTC) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gqlRespond(w, map[string]any{ + "workflowExecution": map[string]any{ + "data": map[string]any{ + "uuid": "exec-uuid-1", + "workflowUUID": "wf-uuid-1", + "workflowName": "Price-Feed", + "status": "FAILURE", + "startedAt": started.Format(time.RFC3339), + "finishedAt": finished.Format(time.RFC3339), + "errors": []any{ + map[string]any{"error": "Invalid JSON: unexpected char", "count": 1}, + }, + }, + }, + }) + })) + t.Cleanup(srv.Close) + + rtCtx := rtCtxFor(t, srv.URL) + h := execution.NewStatusHandlerWithClient(rtCtx, wdcFor(t, srv.URL)) + + out := captureStdout(t, func() { + err := h.Execute(context.Background(), execution.StatusInputs{ExecutionUUID: "exec-uuid-1", OutputFormat: "json"}) + require.NoError(t, err) + }) + + var result map[string]any + require.NoError(t, json.Unmarshal([]byte(out), &result)) + assert.Equal(t, "FAILURE", result["status"]) + errs, _ := result["errors"].([]any) + require.Len(t, errs, 1) + assert.Contains(t, errs[0].(map[string]any)["error"], "Invalid JSON") +} + +// ---- newEvents tests ---- + +func TestEvents_MissingCredentials(t *testing.T) { + t.Parallel() + ctx := &runtime.Context{Logger: nopLogger()} + h := execution.NewEventsHandlerWithClient(ctx, wdcFor(t, "http://unused")) + err := h.Execute(context.Background(), execution.EventsInputs{ExecutionUUID: "x"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "credentials not available") +} + +func TestEvents_JSON(t *testing.T) { + + started := time.Date(2026, 5, 29, 14, 0, 5, 0, time.UTC) + finished := time.Date(2026, 5, 29, 14, 0, 7, 0, time.UTC) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gqlRespond(w, map[string]any{ + "workflowExecutionEvents": map[string]any{ + "data": []any{ + map[string]any{ + "capabilityID": "FetchData", + "status": "SUCCESS", + "method": "GET", + "startedAt": started.Format(time.RFC3339), + "finishedAt": finished.Format(time.RFC3339), + "errors": []any{}, + }, + }, + }, + }) + })) + t.Cleanup(srv.Close) + + rtCtx := rtCtxFor(t, srv.URL) + h := execution.NewEventsHandlerWithClient(rtCtx, wdcFor(t, srv.URL)) + + out := captureStdout(t, func() { + err := h.Execute(context.Background(), execution.EventsInputs{ExecutionUUID: "exec-1", OutputFormat: "json"}) + require.NoError(t, err) + }) + + var result []map[string]any + require.NoError(t, json.Unmarshal([]byte(out), &result)) + require.Len(t, result, 1) + assert.Equal(t, "FetchData", result[0]["capabilityID"]) + assert.Equal(t, "GET", result[0]["method"]) + assert.Equal(t, "2s", result[0]["duration"]) +} + +// ---- newLogs tests ---- + +func TestLogs_MissingCredentials(t *testing.T) { + t.Parallel() + ctx := &runtime.Context{Logger: nopLogger()} + h := execution.NewLogsHandlerWithClient(ctx, wdcFor(t, "http://unused")) + err := h.Execute(context.Background(), execution.LogsInputs{ExecutionUUID: "x"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "credentials not available") +} + +func TestLogs_NodeFilter_ClientSide(t *testing.T) { + + ts := time.Date(2026, 5, 29, 14, 0, 8, 0, time.UTC) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gqlRespond(w, map[string]any{ + "workflowExecutionLogs": map[string]any{ + "data": []any{ + map[string]any{"nodeID": "ProcessData", "message": "Starting transformation", "timestamp": ts.Format(time.RFC3339)}, + map[string]any{"nodeID": "FetchData", "message": "HTTP GET called", "timestamp": ts.Format(time.RFC3339)}, + map[string]any{"nodeID": "ProcessData", "message": "Failed to parse", "timestamp": ts.Format(time.RFC3339)}, + }, + }, + }) + })) + t.Cleanup(srv.Close) + + rtCtx := rtCtxFor(t, srv.URL) + h := execution.NewLogsHandlerWithClient(rtCtx, wdcFor(t, srv.URL)) + + out := captureStdout(t, func() { + err := h.Execute(context.Background(), execution.LogsInputs{ + ExecutionUUID: "exec-1", + NodeFilter: "ProcessData", + OutputFormat: "json", + }) + require.NoError(t, err) + }) + + var result []map[string]any + require.NoError(t, json.Unmarshal([]byte(out), &result)) + // Only ProcessData lines should appear + require.Len(t, result, 2) + for _, row := range result { + assert.Equal(t, "ProcessData", row["nodeID"]) + } +} + +func TestLogs_NoFilter_ReturnsAll(t *testing.T) { + + ts := time.Date(2026, 5, 29, 14, 0, 8, 0, time.UTC) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gqlRespond(w, map[string]any{ + "workflowExecutionLogs": map[string]any{ + "data": []any{ + map[string]any{"nodeID": "A", "message": "msg1", "timestamp": ts.Format(time.RFC3339)}, + map[string]any{"nodeID": "B", "message": "msg2", "timestamp": ts.Format(time.RFC3339)}, + }, + }, + }) + })) + t.Cleanup(srv.Close) + + rtCtx := rtCtxFor(t, srv.URL) + h := execution.NewLogsHandlerWithClient(rtCtx, wdcFor(t, srv.URL)) + + out := captureStdout(t, func() { + err := h.Execute(context.Background(), execution.LogsInputs{ExecutionUUID: "exec-1", OutputFormat: "json"}) + require.NoError(t, err) + }) + + var result []map[string]any + require.NoError(t, json.Unmarshal([]byte(out), &result)) + assert.Len(t, result, 2) +} diff --git a/cmd/workflow/execution/list.go b/cmd/workflow/execution/list.go new file mode 100644 index 00000000..64de44c1 --- /dev/null +++ b/cmd/workflow/execution/list.go @@ -0,0 +1,276 @@ +package execution + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/smartcontractkit/cre-cli/internal/client/graphqlclient" + "github.com/smartcontractkit/cre-cli/internal/client/workflowdataclient" + "github.com/smartcontractkit/cre-cli/internal/credentials" + "github.com/smartcontractkit/cre-cli/internal/runtime" + "github.com/smartcontractkit/cre-cli/internal/settings" + "github.com/smartcontractkit/cre-cli/internal/ui" + "github.com/smartcontractkit/cre-cli/internal/workflowrender" +) + +const outputFormatJSON = "json" + +// ListInputs holds the resolved and validated flag/arg values for `execution list`. +type ListInputs struct { + // WorkflowUUID is the resolved platform UUID to filter by, or nil for all executions. + WorkflowUUID *string + Statuses []workflowdataclient.ExecutionStatus + From *time.Time + To *time.Time + Limit int + OutputFormat string +} + +// ListHandler fetches and renders a list of workflow executions. +type ListHandler struct { + credentials *credentials.Credentials + wdc *workflowdataclient.Client + settings *settings.Settings + nonInteractive bool +} + +func newListHandler(ctx *runtime.Context) *ListHandler { + gql := graphqlclient.New(ctx.Credentials, ctx.EnvironmentSet, ctx.Logger) + wdc := workflowdataclient.New(gql, ctx.Logger) + nonInteractive := false + if ctx.Viper != nil { + nonInteractive = ctx.Viper.GetBool(settings.Flags.NonInteractive.Name) + } + return &ListHandler{ + credentials: ctx.Credentials, + wdc: wdc, + settings: ctx.Settings, + nonInteractive: nonInteractive, + } +} + +// NewListHandlerWithClient builds a ListHandler with a pre-built client (for testing). +func NewListHandlerWithClient(ctx *runtime.Context, wdc *workflowdataclient.Client) *ListHandler { + nonInteractive := false + if ctx.Viper != nil { + nonInteractive = ctx.Viper.GetBool(settings.Flags.NonInteractive.Name) + } + return &ListHandler{ + credentials: ctx.Credentials, + wdc: wdc, + settings: ctx.Settings, + nonInteractive: nonInteractive, + } +} + +// resolveListInputs validates and resolves flag values into ListInputs. +func (h *ListHandler) resolveListInputs( + _ context.Context, + statusFlag, startFlag, endFlag string, + limit int, + outputFormat string, +) (ListInputs, error) { + if outputFormat != "" && outputFormat != outputFormatJSON { + return ListInputs{}, fmt.Errorf("--output %q is not supported; only %q is accepted", outputFormat, outputFormatJSON) + } + + var statuses []workflowdataclient.ExecutionStatus + if statusFlag != "" { + s := workflowdataclient.ExecutionStatus(strings.ToUpper(statusFlag)) + if err := validateExecutionStatus(s); err != nil { + return ListInputs{}, err + } + statuses = []workflowdataclient.ExecutionStatus{s} + } + + var from, to *time.Time + if startFlag != "" { + t, err := time.Parse(time.RFC3339, startFlag) + if err != nil { + return ListInputs{}, fmt.Errorf("--start: invalid ISO8601 datetime %q (expected e.g. 2006-01-02T15:04:05Z)", startFlag) + } + from = &t + } + if endFlag != "" { + t, err := time.Parse(time.RFC3339, endFlag) + if err != nil { + return ListInputs{}, fmt.Errorf("--end: invalid ISO8601 datetime %q (expected e.g. 2006-01-02T15:04:05Z)", endFlag) + } + to = &t + } + + return ListInputs{ + Statuses: statuses, + From: from, + To: to, + Limit: limit, + OutputFormat: outputFormat, + }, nil +} + +// resolveWorkflowUUID accepts either a UUID (detected by length/format) or a +// workflow name, and returns the platform UUID to use as a filter. +func (h *ListHandler) resolveWorkflowUUID(ctx context.Context, arg string) (string, error) { + if looksLikeUUID(arg) { + return arg, nil + } + + // Treat arg as a workflow name — look it up via the platform API. + spinner := ui.NewSpinner() + spinner.Start(fmt.Sprintf("Resolving workflow %q...", arg)) + rows, err := h.wdc.SearchByName(ctx, arg, workflowdataclient.DefaultPageSize) + spinner.Stop() + if err != nil { + return "", fmt.Errorf("resolving workflow name %q: %w", arg, err) + } + + // Exact-name match only (SearchByName is a contains match on the server). + var matches []workflowdataclient.Workflow + for _, r := range rows { + if strings.EqualFold(strings.TrimSpace(r.Name), arg) { + matches = append(matches, r) + } + } + + if len(matches) == 0 { + return "", fmt.Errorf("no workflow found with name %q", arg) + } + + // Prefer an ACTIVE workflow when multiple versions exist. + var active []workflowdataclient.Workflow + for _, r := range matches { + if strings.EqualFold(r.Status, "ACTIVE") { + active = append(active, r) + } + } + if len(active) == 1 { + return active[0].WorkflowID, nil + } + if len(active) > 1 { + return "", fmt.Errorf("multiple ACTIVE workflows named %q found; provide the workflow UUID instead", arg) + } + + // No ACTIVE found — fall back to first match and warn. + if !h.nonInteractive { + ui.Warning(fmt.Sprintf("No ACTIVE deployment for workflow %q; showing executions for the first match (status: %s)", arg, matches[0].Status)) + } + if matches[0].WorkflowID == "" { + return "", fmt.Errorf("workflow %q resolved but has no UUID; try providing the UUID directly", arg) + } + return matches[0].WorkflowID, nil +} + +// ExecuteWithArg resolves workflowArg (UUID or name) then calls Execute. +// It is the entry point used when a positional argument is provided. +func (h *ListHandler) ExecuteWithArg(ctx context.Context, workflowArg string, in ListInputs) error { + uuid, err := h.resolveWorkflowUUID(ctx, workflowArg) + if err != nil { + return err + } + in.WorkflowUUID = &uuid + return h.Execute(ctx, in) +} + +// Execute fetches and renders executions. +func (h *ListHandler) Execute(ctx context.Context, in ListInputs) error { + if h.credentials == nil { + return fmt.Errorf("credentials not available — run `cre login` and retry") + } + + spinner := ui.NewSpinner() + spinner.Start("Fetching executions...") + rows, err := h.wdc.ListExecutions(ctx, workflowdataclient.ListExecutionsInput{ + WorkflowUUID: in.WorkflowUUID, + Statuses: in.Statuses, + From: in.From, + To: in.To, + Limit: in.Limit, + }) + spinner.Stop() + if err != nil { + return err + } + + if in.OutputFormat == outputFormatJSON { + return workflowrender.PrintExecutionsJSON(rows) + } + workflowrender.PrintExecutionsTable(rows) + return nil +} + +func newList(runtimeContext *runtime.Context) *cobra.Command { + var statusFlag string + var startFlag string + var endFlag string + var limit int + var outputFormat string + + cmd := &cobra.Command{ + Use: "list [workflow-uuid-or-name]", + Short: "List recent executions for a workflow", + Long: `List workflow executions from the CRE platform. + +The optional argument accepts either a workflow UUID or a workflow name. +When a name is given the CLI resolves it to the active deployment's UUID. +When omitted, executions across all workflows are returned.`, + Example: "cre workflow execution list\n" + + " cre workflow execution list my-workflow\n" + + " cre workflow execution list 7f3d8a12-b1c2-4d3e-9f0a-1b2c3d4e5f6g\n" + + " cre workflow execution list my-workflow --status FAILURE\n" + + " cre workflow execution list my-workflow --start 2026-01-01T00:00:00Z --end 2026-01-02T00:00:00Z\n" + + " cre workflow execution list my-workflow --limit 50 --output json", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + h := newListHandler(runtimeContext) + in, err := h.resolveListInputs(cmd.Context(), statusFlag, startFlag, endFlag, limit, outputFormat) + if err != nil { + return err + } + if len(args) == 1 { + return h.ExecuteWithArg(cmd.Context(), args[0], in) + } + return h.Execute(cmd.Context(), in) + }, + } + + cmd.Flags().StringVar(&statusFlag, "status", "", "Filter by execution status (TRIGGERED, IN_PROGRESS, SUCCESS, FAILURE)") + cmd.Flags().StringVar(&startFlag, "start", "", "Start of time range in ISO8601 format (e.g. 2026-01-01T00:00:00Z)") + cmd.Flags().StringVar(&endFlag, "end", "", "End of time range in ISO8601 format (e.g. 2026-01-02T00:00:00Z)") + cmd.Flags().IntVar(&limit, "limit", 20, "Maximum number of executions to return (max 100)") + cmd.Flags().StringVar(&outputFormat, "output", "", `Output format: "json" prints a JSON array to stdout`) + + return cmd +} + +// looksLikeUUID returns true when s has the standard UUID shape (8-4-4-4-12). +func looksLikeUUID(s string) bool { + parts := strings.Split(s, "-") + if len(parts) != 5 { + return false + } + lengths := []int{8, 4, 4, 4, 12} + for i, p := range parts { + if len(p) != lengths[i] { + return false + } + } + return true +} + +// validateExecutionStatus returns an error when s is not a known platform status. +func validateExecutionStatus(s workflowdataclient.ExecutionStatus) error { + for _, v := range workflowdataclient.ValidExecutionStatuses { + if s == v { + return nil + } + } + valid := make([]string, len(workflowdataclient.ValidExecutionStatuses)) + for i, v := range workflowdataclient.ValidExecutionStatuses { + valid[i] = string(v) + } + return fmt.Errorf("--status %q is not valid; accepted values: %s", s, strings.Join(valid, ", ")) +} diff --git a/cmd/workflow/execution/logs.go b/cmd/workflow/execution/logs.go new file mode 100644 index 00000000..8165e87b --- /dev/null +++ b/cmd/workflow/execution/logs.go @@ -0,0 +1,95 @@ +package execution + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/smartcontractkit/cre-cli/internal/client/graphqlclient" + "github.com/smartcontractkit/cre-cli/internal/client/workflowdataclient" + "github.com/smartcontractkit/cre-cli/internal/credentials" + "github.com/smartcontractkit/cre-cli/internal/runtime" + "github.com/smartcontractkit/cre-cli/internal/ui" + "github.com/smartcontractkit/cre-cli/internal/workflowrender" +) + +type LogsInputs struct { + ExecutionUUID string + // NodeFilter is applied client-side; the API has no server-side node filter. + NodeFilter string + OutputFormat string +} + +type LogsHandler struct { + credentials *credentials.Credentials + wdc *workflowdataclient.Client +} + +func NewLogsHandler(ctx *runtime.Context) *LogsHandler { + gql := graphqlclient.New(ctx.Credentials, ctx.EnvironmentSet, ctx.Logger) + wdc := workflowdataclient.New(gql, ctx.Logger) + return &LogsHandler{credentials: ctx.Credentials, wdc: wdc} +} + +func NewLogsHandlerWithClient(ctx *runtime.Context, wdc *workflowdataclient.Client) *LogsHandler { + return &LogsHandler{credentials: ctx.Credentials, wdc: wdc} +} + +func resolveLogsInputs(executionUUID, nodeFilter, outputFormat string) (LogsInputs, error) { + if outputFormat != "" && outputFormat != outputFormatJSON { + return LogsInputs{}, fmt.Errorf("--output %q is not supported; only %q is accepted", outputFormat, outputFormatJSON) + } + return LogsInputs{ + ExecutionUUID: executionUUID, + NodeFilter: nodeFilter, + OutputFormat: outputFormat, + }, nil +} + +func (h *LogsHandler) Execute(ctx context.Context, in LogsInputs) error { + if h.credentials == nil { + return fmt.Errorf("credentials not available — run `cre login` and retry") + } + + spinner := ui.NewSpinner() + spinner.Start("Fetching execution logs...") + logs, err := h.wdc.ListExecutionLogs(ctx, in.ExecutionUUID) + spinner.Stop() + if err != nil { + return err + } + + if in.OutputFormat == outputFormatJSON { + return workflowrender.PrintLogsJSON(logs, in.NodeFilter) + } + workflowrender.PrintLogsTable(logs, in.NodeFilter) + return nil +} + +func newLogs(runtimeContext *runtime.Context) *cobra.Command { + var nodeFilter string + var outputFormat string + + cmd := &cobra.Command{ + Use: "logs ", + Short: "Show logs emitted during a workflow execution", + Long: `Fetch and display all log lines emitted during a workflow execution. +Use --node to filter to a specific capability node (client-side filter).`, + Example: "cre workflow execution logs 7f3d8a12-b1c2-4d3e-9f0a-1b2c3d4e5f6g\n" + + " cre workflow execution logs 7f3d8a12-b1c2-4d3e-9f0a-1b2c3d4e5f6g --node ProcessData\n" + + " cre workflow execution logs 7f3d8a12-b1c2-4d3e-9f0a-1b2c3d4e5f6g --output json", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + in, err := resolveLogsInputs(args[0], nodeFilter, outputFormat) + if err != nil { + return err + } + return NewLogsHandler(runtimeContext).Execute(cmd.Context(), in) + }, + } + + cmd.Flags().StringVar(&nodeFilter, "node", "", "Filter logs to a specific node/capability ID (case-insensitive)") + cmd.Flags().StringVar(&outputFormat, "output", "", `Output format: "json" prints a JSON array to stdout`) + return cmd +} diff --git a/cmd/workflow/execution/status.go b/cmd/workflow/execution/status.go new file mode 100644 index 00000000..f978e85e --- /dev/null +++ b/cmd/workflow/execution/status.go @@ -0,0 +1,86 @@ +package execution + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/smartcontractkit/cre-cli/internal/client/graphqlclient" + "github.com/smartcontractkit/cre-cli/internal/client/workflowdataclient" + "github.com/smartcontractkit/cre-cli/internal/credentials" + "github.com/smartcontractkit/cre-cli/internal/runtime" + "github.com/smartcontractkit/cre-cli/internal/ui" + "github.com/smartcontractkit/cre-cli/internal/workflowrender" +) + +type StatusInputs struct { + ExecutionUUID string + OutputFormat string +} + +type StatusHandler struct { + credentials *credentials.Credentials + wdc *workflowdataclient.Client +} + +func NewStatusHandler(ctx *runtime.Context) *StatusHandler { + gql := graphqlclient.New(ctx.Credentials, ctx.EnvironmentSet, ctx.Logger) + wdc := workflowdataclient.New(gql, ctx.Logger) + return &StatusHandler{credentials: ctx.Credentials, wdc: wdc} +} + +func NewStatusHandlerWithClient(ctx *runtime.Context, wdc *workflowdataclient.Client) *StatusHandler { + return &StatusHandler{credentials: ctx.Credentials, wdc: wdc} +} + +func resolveStatusInputs(executionUUID, outputFormat string) (StatusInputs, error) { + if outputFormat != "" && outputFormat != outputFormatJSON { + return StatusInputs{}, fmt.Errorf("--output %q is not supported; only %q is accepted", outputFormat, outputFormatJSON) + } + return StatusInputs{ExecutionUUID: executionUUID, OutputFormat: outputFormat}, nil +} + +func (h *StatusHandler) Execute(ctx context.Context, in StatusInputs) error { + if h.credentials == nil { + return fmt.Errorf("credentials not available — run `cre login` and retry") + } + + spinner := ui.NewSpinner() + spinner.Start("Fetching execution...") + exec, err := h.wdc.GetExecution(ctx, in.ExecutionUUID) + spinner.Stop() + if err != nil { + return err + } + + if in.OutputFormat == outputFormatJSON { + return workflowrender.PrintExecutionDetailJSON(*exec) + } + workflowrender.PrintExecutionDetailTable(*exec) + return nil +} + +func newStatus(runtimeContext *runtime.Context) *cobra.Command { + var outputFormat string + + cmd := &cobra.Command{ + Use: "status ", + Short: "Show detailed status of a single execution", + Long: `Fetch and display the full status of a workflow execution, including +top-level errors when the execution has failed.`, + Example: "cre workflow execution status 7f3d8a12-b1c2-4d3e-9f0a-1b2c3d4e5f6g\n" + + " cre workflow execution status 7f3d8a12-b1c2-4d3e-9f0a-1b2c3d4e5f6g --output json", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + in, err := resolveStatusInputs(args[0], outputFormat) + if err != nil { + return err + } + return NewStatusHandler(runtimeContext).Execute(cmd.Context(), in) + }, + } + + cmd.Flags().StringVar(&outputFormat, "output", "", `Output format: "json" prints JSON to stdout`) + return cmd +} diff --git a/cmd/workflow/workflow.go b/cmd/workflow/workflow.go index c301b83a..8af5ec20 100644 --- a/cmd/workflow/workflow.go +++ b/cmd/workflow/workflow.go @@ -8,6 +8,7 @@ import ( "github.com/smartcontractkit/cre-cli/cmd/workflow/convert" "github.com/smartcontractkit/cre-cli/cmd/workflow/delete" "github.com/smartcontractkit/cre-cli/cmd/workflow/deploy" + "github.com/smartcontractkit/cre-cli/cmd/workflow/execution" workflowget "github.com/smartcontractkit/cre-cli/cmd/workflow/get" "github.com/smartcontractkit/cre-cli/cmd/workflow/hash" "github.com/smartcontractkit/cre-cli/cmd/workflow/limits" @@ -28,6 +29,7 @@ func New(runtimeContext *runtime.Context) *cobra.Command { workflowCmd.AddCommand(supported_chains.New(runtimeContext)) workflowCmd.AddCommand(activate.New(runtimeContext)) + workflowCmd.AddCommand(execution.New(runtimeContext)) workflowCmd.AddCommand(build.New(runtimeContext)) workflowCmd.AddCommand(convert.New(runtimeContext)) workflowCmd.AddCommand(delete.New(runtimeContext)) diff --git a/internal/client/workflowdataclient/execution.go b/internal/client/workflowdataclient/execution.go new file mode 100644 index 00000000..4905171e --- /dev/null +++ b/internal/client/workflowdataclient/execution.go @@ -0,0 +1,386 @@ +package workflowdataclient + +import ( + "context" + "fmt" + "time" + + "github.com/machinebox/graphql" +) + +// ExecutionStatus mirrors the WorkflowExecutionStatus enum from the platform schema. +type ExecutionStatus string + +const ( + ExecutionStatusUnknown ExecutionStatus = "UNKNOWN" + ExecutionStatusUnspecified ExecutionStatus = "UNSPECIFIED" + ExecutionStatusTriggered ExecutionStatus = "TRIGGERED" + ExecutionStatusInProgress ExecutionStatus = "IN_PROGRESS" + ExecutionStatusSuccess ExecutionStatus = "SUCCESS" + ExecutionStatusFailure ExecutionStatus = "FAILURE" +) + +// ValidExecutionStatuses is the full set of values accepted by the platform. +var ValidExecutionStatuses = []ExecutionStatus{ + ExecutionStatusTriggered, + ExecutionStatusInProgress, + ExecutionStatusSuccess, + ExecutionStatusFailure, +} + +// ExecutionError is a top-level error on a workflow execution. +type ExecutionError struct { + Error string + Count int +} + +// Execution is a single workflow execution record. +type Execution struct { + UUID string + WorkflowUUID string + WorkflowName string + Status ExecutionStatus + StartedAt time.Time + FinishedAt *time.Time + CreditUsed *string // CreditAmount scalar serialised as a string + Errors []ExecutionError +} + +// CapabilityExecutionError is an error attached to a capability event. +type CapabilityExecutionError struct { + Error string + Count int +} + +// ExecutionEvent is one node/capability event within an execution. +type ExecutionEvent struct { + CapabilityID string + Status string + StartedAt time.Time + FinishedAt *time.Time + Errors []CapabilityExecutionError + Method *string +} + +// ExecutionLog is a single log line emitted during an execution. +type ExecutionLog struct { + NodeID string + Message string + Timestamp time.Time +} + +// ListExecutionsInput maps to WorkflowExecutionsInput on the platform. +type ListExecutionsInput struct { + WorkflowUUID *string + Statuses []ExecutionStatus + From *time.Time + To *time.Time + // Limit is the maximum number of results to return (capped at 100 by the API). + Limit int +} + +// ListEventsInput maps to WorkflowExecutionEventsInput on the platform. +type ListEventsInput struct { + ExecutionUUID string + CapabilityID *string + Status *string +} + +// ---- GraphQL query strings ---- + +const listExecutionsQuery = ` +query ListExecutions($input: WorkflowExecutionsInput!) { + workflowExecutions(input: $input) { + data { + uuid + workflowUUID + workflowName + status + startedAt + finishedAt + creditUsed + errors { + error + count + } + } + count + } +} +` + +const getExecutionQuery = ` +query GetExecution($input: WorkflowExecutionInput!) { + workflowExecution(input: $input) { + data { + uuid + workflowUUID + workflowName + status + startedAt + finishedAt + creditUsed + errors { + error + count + } + } + } +} +` + +const listExecutionEventsQuery = ` +query ListExecutionEvents($input: WorkflowExecutionEventsInput!) { + workflowExecutionEvents(input: $input) { + data { + capabilityID + status + startedAt + finishedAt + method + errors { + error + count + } + } + } +} +` + +const listExecutionLogsQuery = ` +query ListExecutionLogs($input: WorkflowExecutionLogsInput!) { + workflowExecutionLogs(input: $input) { + data { + nodeID + message + timestamp + } + } +} +` + +// ---- GQL envelope types ---- + +type gqlExecutionError struct { + Error string `json:"error"` + Count int `json:"count"` +} + +type gqlExecution struct { + UUID string `json:"uuid"` + WorkflowUUID string `json:"workflowUUID"` + WorkflowName string `json:"workflowName"` + Status string `json:"status"` + StartedAt time.Time `json:"startedAt"` + FinishedAt *time.Time `json:"finishedAt"` + CreditUsed *string `json:"creditUsed"` + Errors []gqlExecutionError `json:"errors"` +} + +type listExecutionsEnvelope struct { + WorkflowExecutions struct { + Data []gqlExecution `json:"data"` + Count int `json:"count"` + } `json:"workflowExecutions"` +} + +type getExecutionEnvelope struct { + WorkflowExecution struct { + Data *gqlExecution `json:"data"` + } `json:"workflowExecution"` +} + +type gqlCapabilityError struct { + Error string `json:"error"` + Count int `json:"count"` +} + +type gqlExecutionEvent struct { + CapabilityID string `json:"capabilityID"` + Status string `json:"status"` + StartedAt time.Time `json:"startedAt"` + FinishedAt *time.Time `json:"finishedAt"` + Method *string `json:"method"` + Errors []gqlCapabilityError `json:"errors"` +} + +type listEventsEnvelope struct { + WorkflowExecutionEvents struct { + Data []gqlExecutionEvent `json:"data"` + } `json:"workflowExecutionEvents"` +} + +type gqlExecutionLog struct { + NodeID string `json:"nodeID"` + Message string `json:"message"` + Timestamp time.Time `json:"timestamp"` +} + +type listLogsEnvelope struct { + WorkflowExecutionLogs struct { + Data []gqlExecutionLog `json:"data"` + } `json:"workflowExecutionLogs"` +} + +// ---- Client methods ---- + +// ListExecutions fetches workflow executions matching the given filters. +// At most one page of results is returned; Limit controls page size (max 100). +func (c *Client) ListExecutions(parent context.Context, in ListExecutionsInput) ([]Execution, error) { + ctx, cancel := c.CreateServiceContextWithTimeout(parent) + defer cancel() + + limit := in.Limit + if limit <= 0 { + limit = 20 + } + if limit > 100 { + limit = 100 + } + + input := map[string]any{ + "page": map[string]any{ + "number": 0, + "size": limit, + }, + } + if in.WorkflowUUID != nil { + input["workflowUuid"] = *in.WorkflowUUID + } + if len(in.Statuses) > 0 { + ss := make([]string, len(in.Statuses)) + for i, s := range in.Statuses { + ss[i] = string(s) + } + input["status"] = ss + } + if in.From != nil { + input["from"] = in.From.UTC().Format(time.RFC3339) + } + if in.To != nil { + input["to"] = in.To.UTC().Format(time.RFC3339) + } + + req := graphql.NewRequest(listExecutionsQuery) + req.Var("input", input) + + var env listExecutionsEnvelope + if err := c.graphql.Execute(ctx, req, &env); err != nil { + return nil, fmt.Errorf("list executions: %w", err) + } + + return toExecutions(env.WorkflowExecutions.Data), nil +} + +// GetExecution fetches a single execution by its UUID. +func (c *Client) GetExecution(parent context.Context, uuid string) (*Execution, error) { + ctx, cancel := c.CreateServiceContextWithTimeout(parent) + defer cancel() + + req := graphql.NewRequest(getExecutionQuery) + req.Var("input", map[string]any{"uuid": uuid}) + + var env getExecutionEnvelope + if err := c.graphql.Execute(ctx, req, &env); err != nil { + return nil, fmt.Errorf("get execution: %w", err) + } + + if env.WorkflowExecution.Data == nil { + return nil, fmt.Errorf("execution %q not found", uuid) + } + + e := toExecution(*env.WorkflowExecution.Data) + return &e, nil +} + +// ListExecutionEvents fetches all node/capability events for an execution. +func (c *Client) ListExecutionEvents(parent context.Context, in ListEventsInput) ([]ExecutionEvent, error) { + ctx, cancel := c.CreateServiceContextWithTimeout(parent) + defer cancel() + + input := map[string]any{ + "workflowExecutionUUID": in.ExecutionUUID, + } + if in.CapabilityID != nil { + input["capabilityID"] = *in.CapabilityID + } + if in.Status != nil { + input["status"] = *in.Status + } + + req := graphql.NewRequest(listExecutionEventsQuery) + req.Var("input", input) + + var env listEventsEnvelope + if err := c.graphql.Execute(ctx, req, &env); err != nil { + return nil, fmt.Errorf("list execution events: %w", err) + } + + events := make([]ExecutionEvent, 0, len(env.WorkflowExecutionEvents.Data)) + for _, g := range env.WorkflowExecutionEvents.Data { + errs := make([]CapabilityExecutionError, 0, len(g.Errors)) + for _, e := range g.Errors { + errs = append(errs, CapabilityExecutionError{Error: e.Error, Count: e.Count}) + } + events = append(events, ExecutionEvent{ + CapabilityID: g.CapabilityID, + Status: g.Status, + StartedAt: g.StartedAt, + FinishedAt: g.FinishedAt, + Method: g.Method, + Errors: errs, + }) + } + return events, nil +} + +// ListExecutionLogs fetches all log lines for an execution. +func (c *Client) ListExecutionLogs(parent context.Context, executionUUID string) ([]ExecutionLog, error) { + ctx, cancel := c.CreateServiceContextWithTimeout(parent) + defer cancel() + + req := graphql.NewRequest(listExecutionLogsQuery) + req.Var("input", map[string]any{"workflowExecutionUUID": executionUUID}) + + var env listLogsEnvelope + if err := c.graphql.Execute(ctx, req, &env); err != nil { + return nil, fmt.Errorf("list execution logs: %w", err) + } + + logs := make([]ExecutionLog, 0, len(env.WorkflowExecutionLogs.Data)) + for _, g := range env.WorkflowExecutionLogs.Data { + logs = append(logs, ExecutionLog{ + NodeID: g.NodeID, + Message: g.Message, + Timestamp: g.Timestamp, + }) + } + return logs, nil +} + +// ---- helpers ---- + +func toExecution(g gqlExecution) Execution { + errs := make([]ExecutionError, 0, len(g.Errors)) + for _, e := range g.Errors { + errs = append(errs, ExecutionError{Error: e.Error, Count: e.Count}) + } + return Execution{ + UUID: g.UUID, + WorkflowUUID: g.WorkflowUUID, + WorkflowName: g.WorkflowName, + Status: ExecutionStatus(g.Status), + StartedAt: g.StartedAt, + FinishedAt: g.FinishedAt, + CreditUsed: g.CreditUsed, + Errors: errs, + } +} + +func toExecutions(gs []gqlExecution) []Execution { + out := make([]Execution, 0, len(gs)) + for _, g := range gs { + out = append(out, toExecution(g)) + } + return out +} diff --git a/internal/client/workflowdataclient/workflowdataclient.go b/internal/client/workflowdataclient/workflowdataclient.go index 5c9fb087..19c5d16b 100644 --- a/internal/client/workflowdataclient/workflowdataclient.go +++ b/internal/client/workflowdataclient/workflowdataclient.go @@ -117,7 +117,13 @@ func (c *Client) list(parent context.Context, pageSize int, search string) ([]Wo batch := env.Workflows.Data for _, g := range batch { - all = append(all, Workflow(g)) + all = append(all, Workflow{ + Name: g.Name, + WorkflowID: g.WorkflowID, + OwnerAddress: g.OwnerAddress, + Status: g.Status, + WorkflowSource: g.WorkflowSource, + }) } if len(all) >= total || len(batch) == 0 { diff --git a/internal/workflowrender/execution.go b/internal/workflowrender/execution.go new file mode 100644 index 00000000..a9069bed --- /dev/null +++ b/internal/workflowrender/execution.go @@ -0,0 +1,298 @@ +package workflowrender + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/smartcontractkit/cre-cli/internal/client/workflowdataclient" + "github.com/smartcontractkit/cre-cli/internal/ui" +) + +// ---- List executions ---- + +type executionJSON struct { + UUID string `json:"uuid"` + WorkflowUUID string `json:"workflowUUID"` + WorkflowName string `json:"workflowName"` + Status string `json:"status"` + StartedAt string `json:"startedAt"` + FinishedAt *string `json:"finishedAt,omitempty"` + DurationSec *string `json:"duration,omitempty"` + CreditUsed *string `json:"creditUsed,omitempty"` +} + +func toExecutionJSON(e workflowdataclient.Execution) executionJSON { + started := e.StartedAt.UTC().Format(time.RFC3339) + j := executionJSON{ + UUID: e.UUID, + WorkflowUUID: e.WorkflowUUID, + WorkflowName: e.WorkflowName, + Status: string(e.Status), + StartedAt: started, + CreditUsed: e.CreditUsed, + } + if e.FinishedAt != nil { + f := e.FinishedAt.UTC().Format(time.RFC3339) + j.FinishedAt = &f + d := formatDuration(e.FinishedAt.Sub(e.StartedAt)) + j.DurationSec = &d + } + return j +} + +// PrintExecutionsJSON marshals a slice of executions as an indented JSON array to stdout. +func PrintExecutionsJSON(rows []workflowdataclient.Execution) error { + out := make([]executionJSON, 0, len(rows)) + for _, e := range rows { + out = append(out, toExecutionJSON(e)) + } + data, err := json.MarshalIndent(out, "", " ") + if err != nil { + return err + } + fmt.Println(string(data)) + return nil +} + +// PrintExecutionsTable renders executions as a bulleted list to stdout. +func PrintExecutionsTable(rows []workflowdataclient.Execution) { + ui.Line() + if len(rows) == 0 { + ui.Warning("No executions found") + ui.Line() + return + } + + ui.Bold("Executions") + ui.Line() + + for i, e := range rows { + ui.Bold(fmt.Sprintf("%d. %s", i+1, e.UUID)) + ui.Dim(fmt.Sprintf(" Workflow: %s (%s)", e.WorkflowName, e.WorkflowUUID)) + ui.Dim(fmt.Sprintf(" Status: %s", e.Status)) + ui.Dim(fmt.Sprintf(" Started: %s", e.StartedAt.UTC().Format("2006-01-02 15:04:05"))) + if e.FinishedAt != nil { + ui.Dim(fmt.Sprintf(" Finished: %s (%s)", e.FinishedAt.UTC().Format("2006-01-02 15:04:05"), formatDuration(e.FinishedAt.Sub(e.StartedAt)))) + } + if e.CreditUsed != nil { + ui.Dim(fmt.Sprintf(" Credits: %s", *e.CreditUsed)) + } + ui.Line() + } +} + +// ---- Single execution detail (status command) ---- + +type executionDetailJSON struct { + executionJSON + Errors []executionErrorJSON `json:"errors,omitempty"` +} + +type executionErrorJSON struct { + Error string `json:"error"` + Count int `json:"count"` +} + +// PrintExecutionDetailJSON marshals a single execution with its errors to stdout. +func PrintExecutionDetailJSON(e workflowdataclient.Execution) error { + errs := make([]executionErrorJSON, 0, len(e.Errors)) + for _, err := range e.Errors { + errs = append(errs, executionErrorJSON{Error: err.Error, Count: err.Count}) + } + detail := executionDetailJSON{ + executionJSON: toExecutionJSON(e), + Errors: errs, + } + data, err := json.MarshalIndent(detail, "", " ") + if err != nil { + return err + } + fmt.Println(string(data)) + return nil +} + +// PrintExecutionDetailTable renders a single execution with its errors as a bulleted detail view. +func PrintExecutionDetailTable(e workflowdataclient.Execution) { + ui.Line() + ui.Bold(fmt.Sprintf("Execution: %s", e.UUID)) + ui.Dim(fmt.Sprintf(" Workflow: %s", e.WorkflowName)) + ui.Dim(fmt.Sprintf(" UUID: %s", e.WorkflowUUID)) + ui.Dim(fmt.Sprintf(" Status: %s", e.Status)) + + timeStr := e.StartedAt.UTC().Format("2006-01-02 15:04:05") + if e.FinishedAt != nil { + timeStr = fmt.Sprintf("%s to %s (%s)", + e.StartedAt.UTC().Format("2006-01-02 15:04:05"), + e.FinishedAt.UTC().Format("15:04:05"), + formatDuration(e.FinishedAt.Sub(e.StartedAt)), + ) + } + ui.Dim(fmt.Sprintf(" Time: %s", timeStr)) + + if e.CreditUsed != nil { + ui.Dim(fmt.Sprintf(" Credits: %s", *e.CreditUsed)) + } + + if len(e.Errors) > 0 { + ui.Line() + ui.Bold("Top-Level Errors:") + for _, err := range e.Errors { + ui.Dim(fmt.Sprintf(" - %s (Count: %d)", err.Error, err.Count)) + } + } + + ui.Line() +} + +// ---- Events ---- + +type eventJSON struct { + CapabilityID string `json:"capabilityID"` + Status string `json:"status"` + Method *string `json:"method,omitempty"` + StartedAt string `json:"startedAt"` + FinishedAt *string `json:"finishedAt,omitempty"` + Duration *string `json:"duration,omitempty"` + Errors []capabilityErrorJSON `json:"errors,omitempty"` +} + +type capabilityErrorJSON struct { + Error string `json:"error"` + Count int `json:"count"` +} + +// PrintEventsJSON marshals events as an indented JSON array to stdout. +func PrintEventsJSON(events []workflowdataclient.ExecutionEvent) error { + out := make([]eventJSON, 0, len(events)) + for _, ev := range events { + j := eventJSON{ + CapabilityID: ev.CapabilityID, + Status: ev.Status, + Method: ev.Method, + StartedAt: ev.StartedAt.UTC().Format(time.RFC3339), + } + if ev.FinishedAt != nil { + f := ev.FinishedAt.UTC().Format(time.RFC3339) + j.FinishedAt = &f + d := formatDuration(ev.FinishedAt.Sub(ev.StartedAt)) + j.Duration = &d + } + for _, e := range ev.Errors { + j.Errors = append(j.Errors, capabilityErrorJSON{Error: e.Error, Count: e.Count}) + } + out = append(out, j) + } + data, err := json.MarshalIndent(out, "", " ") + if err != nil { + return err + } + fmt.Println(string(data)) + return nil +} + +// PrintEventsTable renders events as a bulleted list to stdout. +func PrintEventsTable(events []workflowdataclient.ExecutionEvent) { + ui.Line() + if len(events) == 0 { + ui.Warning("No events found") + ui.Line() + return + } + + ui.Bold("Events") + ui.Line() + + for i, ev := range events { + method := "-" + if ev.Method != nil && *ev.Method != "" { + method = *ev.Method + } + dur := "-" + if ev.FinishedAt != nil { + dur = formatDuration(ev.FinishedAt.Sub(ev.StartedAt)) + } + + ui.Bold(fmt.Sprintf("%d. %s", i+1, ev.CapabilityID)) + ui.Dim(fmt.Sprintf(" Method: %s", method)) + ui.Dim(fmt.Sprintf(" Status: %s", ev.Status)) + ui.Dim(fmt.Sprintf(" Started: %s", ev.StartedAt.UTC().Format("2006-01-02 15:04:05"))) + ui.Dim(fmt.Sprintf(" Duration: %s", dur)) + if len(ev.Errors) > 0 { + errMsgs := make([]string, 0, len(ev.Errors)) + for _, e := range ev.Errors { + errMsgs = append(errMsgs, fmt.Sprintf("%s (x%d)", e.Error, e.Count)) + } + ui.Dim(fmt.Sprintf(" Errors: %s", strings.Join(errMsgs, "; "))) + } + ui.Line() + } +} + +// ---- Logs ---- + +type logJSON struct { + NodeID string `json:"nodeID"` + Timestamp string `json:"timestamp"` + Message string `json:"message"` +} + +// PrintLogsJSON marshals logs as an indented JSON array to stdout. +// nodeFilter, if non-empty, restricts output to lines whose NodeID matches (case-insensitive). +func PrintLogsJSON(logs []workflowdataclient.ExecutionLog, nodeFilter string) error { + out := make([]logJSON, 0, len(logs)) + for _, l := range logs { + if nodeFilter != "" && !strings.EqualFold(l.NodeID, nodeFilter) { + continue + } + out = append(out, logJSON{ + NodeID: l.NodeID, + Timestamp: l.Timestamp.UTC().Format(time.RFC3339), + Message: l.Message, + }) + } + data, err := json.MarshalIndent(out, "", " ") + if err != nil { + return err + } + fmt.Println(string(data)) + return nil +} + +// PrintLogsTable renders log lines to stdout. +// nodeFilter, if non-empty, restricts output to lines whose NodeID matches (case-insensitive). +func PrintLogsTable(logs []workflowdataclient.ExecutionLog, nodeFilter string) { + ui.Line() + printed := 0 + for _, l := range logs { + if nodeFilter != "" && !strings.EqualFold(l.NodeID, nodeFilter) { + continue + } + ui.Print(fmt.Sprintf("[%s] [%s] %s", + l.Timestamp.UTC().Format("2006-01-02 15:04:05"), + l.NodeID, + l.Message, + )) + printed++ + } + if printed == 0 { + ui.Warning("No logs found") + } + ui.Line() +} + +// ---- shared helpers ---- + +func formatDuration(d time.Duration) string { + if d < 0 { + d = 0 + } + if d < time.Second { + return fmt.Sprintf("%dms", d.Milliseconds()) + } + if d < time.Minute { + return fmt.Sprintf("%.0fs", d.Seconds()) + } + return fmt.Sprintf("%.1fm", d.Minutes()) +} From 18ab3dd9dfbd8d9e93a5e495fb873b8a8f06ca53 Mon Sep 17 00:00:00 2001 From: Emmanuel Jacquier Date: Sun, 31 May 2026 09:58:53 -0400 Subject: [PATCH 02/14] added --json shorthand --- cmd/workflow/execution/events.go | 9 +++++++-- cmd/workflow/execution/list.go | 8 +++++++- cmd/workflow/execution/logs.go | 9 +++++++-- cmd/workflow/execution/status.go | 9 +++++++-- 4 files changed, 28 insertions(+), 7 deletions(-) diff --git a/cmd/workflow/execution/events.go b/cmd/workflow/execution/events.go index de512d9e..8c53281b 100644 --- a/cmd/workflow/execution/events.go +++ b/cmd/workflow/execution/events.go @@ -36,7 +36,10 @@ func NewEventsHandlerWithClient(ctx *runtime.Context, wdc *workflowdataclient.Cl return &EventsHandler{credentials: ctx.Credentials, wdc: wdc} } -func resolveEventsInputs(executionUUID, capabilityID, status, outputFormat string) (EventsInputs, error) { +func resolveEventsInputs(executionUUID, capabilityID, status, outputFormat string, jsonFlag bool) (EventsInputs, error) { + if jsonFlag { + outputFormat = outputFormatJSON + } if outputFormat != "" && outputFormat != outputFormatJSON { return EventsInputs{}, fmt.Errorf("--output %q is not supported; only %q is accepted", outputFormat, outputFormatJSON) } @@ -81,6 +84,7 @@ func newEvents(runtimeContext *runtime.Context) *cobra.Command { var capabilityID string var status string var outputFormat string + var jsonFlag bool cmd := &cobra.Command{ Use: "events ", @@ -93,7 +97,7 @@ execution, including per-event status, method, duration, and any errors.`, " cre workflow execution events 7f3d8a12-b1c2-4d3e-9f0a-1b2c3d4e5f6g --output json", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - in, err := resolveEventsInputs(args[0], capabilityID, status, outputFormat) + in, err := resolveEventsInputs(args[0], capabilityID, status, outputFormat, jsonFlag) if err != nil { return err } @@ -104,5 +108,6 @@ execution, including per-event status, method, duration, and any errors.`, cmd.Flags().StringVar(&capabilityID, "capability", "", "Filter events to a specific capability ID") cmd.Flags().StringVar(&status, "status", "", "Filter events by status (e.g. FAILURE)") cmd.Flags().StringVar(&outputFormat, "output", "", `Output format: "json" prints a JSON array to stdout`) + cmd.Flags().BoolVar(&jsonFlag, "json", false, "Output as JSON (shorthand for --output=json)") return cmd } diff --git a/cmd/workflow/execution/list.go b/cmd/workflow/execution/list.go index 64de44c1..1d8b9394 100644 --- a/cmd/workflow/execution/list.go +++ b/cmd/workflow/execution/list.go @@ -73,7 +73,11 @@ func (h *ListHandler) resolveListInputs( statusFlag, startFlag, endFlag string, limit int, outputFormat string, + jsonFlag bool, ) (ListInputs, error) { + if jsonFlag { + outputFormat = outputFormatJSON + } if outputFormat != "" && outputFormat != outputFormatJSON { return ListInputs{}, fmt.Errorf("--output %q is not supported; only %q is accepted", outputFormat, outputFormatJSON) } @@ -208,6 +212,7 @@ func newList(runtimeContext *runtime.Context) *cobra.Command { var endFlag string var limit int var outputFormat string + var jsonFlag bool cmd := &cobra.Command{ Use: "list [workflow-uuid-or-name]", @@ -226,7 +231,7 @@ When omitted, executions across all workflows are returned.`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { h := newListHandler(runtimeContext) - in, err := h.resolveListInputs(cmd.Context(), statusFlag, startFlag, endFlag, limit, outputFormat) + in, err := h.resolveListInputs(cmd.Context(), statusFlag, startFlag, endFlag, limit, outputFormat, jsonFlag) if err != nil { return err } @@ -242,6 +247,7 @@ When omitted, executions across all workflows are returned.`, cmd.Flags().StringVar(&endFlag, "end", "", "End of time range in ISO8601 format (e.g. 2026-01-02T00:00:00Z)") cmd.Flags().IntVar(&limit, "limit", 20, "Maximum number of executions to return (max 100)") cmd.Flags().StringVar(&outputFormat, "output", "", `Output format: "json" prints a JSON array to stdout`) + cmd.Flags().BoolVar(&jsonFlag, "json", false, "Output as JSON (shorthand for --output=json)") return cmd } diff --git a/cmd/workflow/execution/logs.go b/cmd/workflow/execution/logs.go index 8165e87b..1fc60e3d 100644 --- a/cmd/workflow/execution/logs.go +++ b/cmd/workflow/execution/logs.go @@ -36,7 +36,10 @@ func NewLogsHandlerWithClient(ctx *runtime.Context, wdc *workflowdataclient.Clie return &LogsHandler{credentials: ctx.Credentials, wdc: wdc} } -func resolveLogsInputs(executionUUID, nodeFilter, outputFormat string) (LogsInputs, error) { +func resolveLogsInputs(executionUUID, nodeFilter, outputFormat string, jsonFlag bool) (LogsInputs, error) { + if jsonFlag { + outputFormat = outputFormatJSON + } if outputFormat != "" && outputFormat != outputFormatJSON { return LogsInputs{}, fmt.Errorf("--output %q is not supported; only %q is accepted", outputFormat, outputFormatJSON) } @@ -70,6 +73,7 @@ func (h *LogsHandler) Execute(ctx context.Context, in LogsInputs) error { func newLogs(runtimeContext *runtime.Context) *cobra.Command { var nodeFilter string var outputFormat string + var jsonFlag bool cmd := &cobra.Command{ Use: "logs ", @@ -81,7 +85,7 @@ Use --node to filter to a specific capability node (client-side filter).`, " cre workflow execution logs 7f3d8a12-b1c2-4d3e-9f0a-1b2c3d4e5f6g --output json", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - in, err := resolveLogsInputs(args[0], nodeFilter, outputFormat) + in, err := resolveLogsInputs(args[0], nodeFilter, outputFormat, jsonFlag) if err != nil { return err } @@ -91,5 +95,6 @@ Use --node to filter to a specific capability node (client-side filter).`, cmd.Flags().StringVar(&nodeFilter, "node", "", "Filter logs to a specific node/capability ID (case-insensitive)") cmd.Flags().StringVar(&outputFormat, "output", "", `Output format: "json" prints a JSON array to stdout`) + cmd.Flags().BoolVar(&jsonFlag, "json", false, "Output as JSON (shorthand for --output=json)") return cmd } diff --git a/cmd/workflow/execution/status.go b/cmd/workflow/execution/status.go index f978e85e..68893e29 100644 --- a/cmd/workflow/execution/status.go +++ b/cmd/workflow/execution/status.go @@ -34,7 +34,10 @@ func NewStatusHandlerWithClient(ctx *runtime.Context, wdc *workflowdataclient.Cl return &StatusHandler{credentials: ctx.Credentials, wdc: wdc} } -func resolveStatusInputs(executionUUID, outputFormat string) (StatusInputs, error) { +func resolveStatusInputs(executionUUID, outputFormat string, jsonFlag bool) (StatusInputs, error) { + if jsonFlag { + outputFormat = outputFormatJSON + } if outputFormat != "" && outputFormat != outputFormatJSON { return StatusInputs{}, fmt.Errorf("--output %q is not supported; only %q is accepted", outputFormat, outputFormatJSON) } @@ -63,6 +66,7 @@ func (h *StatusHandler) Execute(ctx context.Context, in StatusInputs) error { func newStatus(runtimeContext *runtime.Context) *cobra.Command { var outputFormat string + var jsonFlag bool cmd := &cobra.Command{ Use: "status ", @@ -73,7 +77,7 @@ top-level errors when the execution has failed.`, " cre workflow execution status 7f3d8a12-b1c2-4d3e-9f0a-1b2c3d4e5f6g --output json", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - in, err := resolveStatusInputs(args[0], outputFormat) + in, err := resolveStatusInputs(args[0], outputFormat, jsonFlag) if err != nil { return err } @@ -82,5 +86,6 @@ top-level errors when the execution has failed.`, } cmd.Flags().StringVar(&outputFormat, "output", "", `Output format: "json" prints JSON to stdout`) + cmd.Flags().BoolVar(&jsonFlag, "json", false, "Output as JSON (shorthand for --output=json)") return cmd } From 8668c2242fc255c0ff03fac06c6ee461b4801f39 Mon Sep 17 00:00:00 2001 From: Emmanuel Jacquier Date: Mon, 1 Jun 2026 07:19:37 -0400 Subject: [PATCH 03/14] fixed tests &lint --- cmd/root.go | 14 +++++++------- internal/client/workflowdataclient/execution.go | 10 +++------- .../workflowdataclient/workflowdataclient.go | 8 +------- internal/workflowrender/execution.go | 12 ++++++------ 4 files changed, 17 insertions(+), 27 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index b4d30214..435f7e6a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -536,13 +536,13 @@ func isLoadSettings(cmd *cobra.Command) bool { "cre workflow limits": {}, "cre workflow limits export": {}, "cre workflow build": {}, - "cre workflow list": {}, - "cre workflow execution": {}, - "cre workflow execution list": {}, - "cre workflow execution status": {}, - "cre workflow execution events": {}, - "cre workflow execution logs": {}, - "cre account": {}, + "cre workflow list": {}, + "cre workflow execution": {}, + "cre workflow execution list": {}, + "cre workflow execution status": {}, + "cre workflow execution events": {}, + "cre workflow execution logs": {}, + "cre account": {}, "cre secrets": {}, "cre templates": {}, "cre templates list": {}, diff --git a/internal/client/workflowdataclient/execution.go b/internal/client/workflowdataclient/execution.go index 4905171e..4d442942 100644 --- a/internal/client/workflowdataclient/execution.go +++ b/internal/client/workflowdataclient/execution.go @@ -320,7 +320,7 @@ func (c *Client) ListExecutionEvents(parent context.Context, in ListEventsInput) for _, g := range env.WorkflowExecutionEvents.Data { errs := make([]CapabilityExecutionError, 0, len(g.Errors)) for _, e := range g.Errors { - errs = append(errs, CapabilityExecutionError{Error: e.Error, Count: e.Count}) + errs = append(errs, CapabilityExecutionError(e)) } events = append(events, ExecutionEvent{ CapabilityID: g.CapabilityID, @@ -349,11 +349,7 @@ func (c *Client) ListExecutionLogs(parent context.Context, executionUUID string) logs := make([]ExecutionLog, 0, len(env.WorkflowExecutionLogs.Data)) for _, g := range env.WorkflowExecutionLogs.Data { - logs = append(logs, ExecutionLog{ - NodeID: g.NodeID, - Message: g.Message, - Timestamp: g.Timestamp, - }) + logs = append(logs, ExecutionLog(g)) } return logs, nil } @@ -363,7 +359,7 @@ func (c *Client) ListExecutionLogs(parent context.Context, executionUUID string) func toExecution(g gqlExecution) Execution { errs := make([]ExecutionError, 0, len(g.Errors)) for _, e := range g.Errors { - errs = append(errs, ExecutionError{Error: e.Error, Count: e.Count}) + errs = append(errs, ExecutionError(e)) } return Execution{ UUID: g.UUID, diff --git a/internal/client/workflowdataclient/workflowdataclient.go b/internal/client/workflowdataclient/workflowdataclient.go index 19c5d16b..5c9fb087 100644 --- a/internal/client/workflowdataclient/workflowdataclient.go +++ b/internal/client/workflowdataclient/workflowdataclient.go @@ -117,13 +117,7 @@ func (c *Client) list(parent context.Context, pageSize int, search string) ([]Wo batch := env.Workflows.Data for _, g := range batch { - all = append(all, Workflow{ - Name: g.Name, - WorkflowID: g.WorkflowID, - OwnerAddress: g.OwnerAddress, - Status: g.Status, - WorkflowSource: g.WorkflowSource, - }) + all = append(all, Workflow(g)) } if len(all) >= total || len(batch) == 0 { diff --git a/internal/workflowrender/execution.go b/internal/workflowrender/execution.go index a9069bed..18f2d513 100644 --- a/internal/workflowrender/execution.go +++ b/internal/workflowrender/execution.go @@ -149,12 +149,12 @@ func PrintExecutionDetailTable(e workflowdataclient.Execution) { // ---- Events ---- type eventJSON struct { - CapabilityID string `json:"capabilityID"` - Status string `json:"status"` - Method *string `json:"method,omitempty"` - StartedAt string `json:"startedAt"` - FinishedAt *string `json:"finishedAt,omitempty"` - Duration *string `json:"duration,omitempty"` + CapabilityID string `json:"capabilityID"` + Status string `json:"status"` + Method *string `json:"method,omitempty"` + StartedAt string `json:"startedAt"` + FinishedAt *string `json:"finishedAt,omitempty"` + Duration *string `json:"duration,omitempty"` Errors []capabilityErrorJSON `json:"errors,omitempty"` } From 8bd04728f77c4f33ea8a1b63386828c65ac1f96a Mon Sep 17 00:00:00 2001 From: Emmanuel Jacquier Date: Mon, 1 Jun 2026 07:42:21 -0400 Subject: [PATCH 04/14] Fixed list workflow execution by name --- cmd/workflow/execution/execution_test.go | 3 ++- cmd/workflow/execution/list.go | 6 +++--- internal/client/workflowdataclient/workflowdataclient.go | 3 +++ 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/cmd/workflow/execution/execution_test.go b/cmd/workflow/execution/execution_test.go index 3622868b..5333683f 100644 --- a/cmd/workflow/execution/execution_test.go +++ b/cmd/workflow/execution/execution_test.go @@ -159,8 +159,9 @@ func TestList_ByName_ResolvesActiveWorkflow(t *testing.T) { "count": 1, "data": []any{ map[string]any{ + "uuid": "wf-uuid-active", "name": "my-workflow", - "workflowId": "wf-uuid-active", + "workflowId": "abc123onchain", "ownerAddress": "0xowner", "status": "ACTIVE", "workflowSource": "private", diff --git a/cmd/workflow/execution/list.go b/cmd/workflow/execution/list.go index 1d8b9394..e2c0c380 100644 --- a/cmd/workflow/execution/list.go +++ b/cmd/workflow/execution/list.go @@ -152,7 +152,7 @@ func (h *ListHandler) resolveWorkflowUUID(ctx context.Context, arg string) (stri } } if len(active) == 1 { - return active[0].WorkflowID, nil + return active[0].UUID, nil } if len(active) > 1 { return "", fmt.Errorf("multiple ACTIVE workflows named %q found; provide the workflow UUID instead", arg) @@ -162,10 +162,10 @@ func (h *ListHandler) resolveWorkflowUUID(ctx context.Context, arg string) (stri if !h.nonInteractive { ui.Warning(fmt.Sprintf("No ACTIVE deployment for workflow %q; showing executions for the first match (status: %s)", arg, matches[0].Status)) } - if matches[0].WorkflowID == "" { + if matches[0].UUID == "" { return "", fmt.Errorf("workflow %q resolved but has no UUID; try providing the UUID directly", arg) } - return matches[0].WorkflowID, nil + return matches[0].UUID, nil } // ExecuteWithArg resolves workflowArg (UUID or name) then calls Execute. diff --git a/internal/client/workflowdataclient/workflowdataclient.go b/internal/client/workflowdataclient/workflowdataclient.go index 5c9fb087..14e561c8 100644 --- a/internal/client/workflowdataclient/workflowdataclient.go +++ b/internal/client/workflowdataclient/workflowdataclient.go @@ -15,6 +15,7 @@ const DefaultPageSize = 100 // Workflow is a workflow row returned by the platform list API. type Workflow struct { + UUID string Name string WorkflowID string OwnerAddress string @@ -46,6 +47,7 @@ const listWorkflowsQuery = ` query ListWorkflows($input: WorkflowsInput!) { workflows(input: $input) { data { + uuid name workflowId ownerAddress @@ -58,6 +60,7 @@ query ListWorkflows($input: WorkflowsInput!) { ` type gqlWorkflow struct { + UUID string `json:"uuid"` Name string `json:"name"` WorkflowID string `json:"workflowId"` OwnerAddress string `json:"ownerAddress"` From 9e7c6fef25ac607ce2c736e55c716a1aecc562a2 Mon Sep 17 00:00:00 2001 From: Emmanuel Jacquier Date: Mon, 1 Jun 2026 08:52:34 -0400 Subject: [PATCH 05/14] Updated instruction for workflow list --- cmd/workflow/deploy/register.go | 8 ++ .../registry_deploy_strategy_private.go | 8 ++ cmd/workflow/execution/list.go | 74 ++++++++++++------- cmd/workflow/list/list.go | 11 +++ 4 files changed, 76 insertions(+), 25 deletions(-) diff --git a/cmd/workflow/deploy/register.go b/cmd/workflow/deploy/register.go index 29c6ac67..7edb9ae2 100644 --- a/cmd/workflow/deploy/register.go +++ b/cmd/workflow/deploy/register.go @@ -78,6 +78,14 @@ func (h *handler) handleUpsert(params client.RegisterWorkflowV2Parameters, onCha if h.inputs.ConfigURL != nil && *h.inputs.ConfigURL != "" { ui.Dim(fmt.Sprintf(" Config URL: %s", *h.inputs.ConfigURL)) } + ui.Line() + ui.Bold("Next steps:") + ui.Dim(" cre workflow list") + ui.Dim(fmt.Sprintf(" cre workflow execution list %s", workflowName)) + ui.Dim(fmt.Sprintf(" cre workflow execution list %s --status FAILURE", workflowName)) + ui.Dim(" cre workflow execution status ") + ui.Dim(" cre workflow execution events ") + ui.Dim(" cre workflow execution logs ") case client.Raw: ui.Line() diff --git a/cmd/workflow/deploy/registry_deploy_strategy_private.go b/cmd/workflow/deploy/registry_deploy_strategy_private.go index 9909800e..d5bcb14f 100644 --- a/cmd/workflow/deploy/registry_deploy_strategy_private.go +++ b/cmd/workflow/deploy/registry_deploy_strategy_private.go @@ -76,6 +76,14 @@ func (a *privateRegistryDeployStrategy) Upsert() error { if result.Owner != "" { ui.Dim(fmt.Sprintf(" Owner: %s", result.Owner)) } + ui.Line() + ui.Bold("Next steps:") + ui.Dim(" cre workflow list") + ui.Dim(fmt.Sprintf(" cre workflow execution list %s", result.WorkflowName)) + ui.Dim(fmt.Sprintf(" cre workflow execution list %s --status FAILURE", result.WorkflowName)) + ui.Dim(" cre workflow execution status ") + ui.Dim(" cre workflow execution events ") + ui.Dim(" cre workflow execution logs ") return nil } diff --git a/cmd/workflow/execution/list.go b/cmd/workflow/execution/list.go index e2c0c380..6df29fba 100644 --- a/cmd/workflow/execution/list.go +++ b/cmd/workflow/execution/list.go @@ -116,32 +116,57 @@ func (h *ListHandler) resolveListInputs( }, nil } -// resolveWorkflowUUID accepts either a UUID (detected by length/format) or a -// workflow name, and returns the platform UUID to use as a filter. +// resolveWorkflowUUID resolves the platform UUID for a workflow given either its +// on-chain WorkflowID (64-char hex hash) or its name. UUID is used internally +// only and never surfaced to the user. func (h *ListHandler) resolveWorkflowUUID(ctx context.Context, arg string) (string, error) { - if looksLikeUUID(arg) { - return arg, nil + if looksLikeWorkflowID(arg) { + return h.resolveByWorkflowID(ctx, arg) } + return h.resolveByName(ctx, arg) +} + +// resolveByWorkflowID finds the platform UUID for a given on-chain WorkflowID hash. +func (h *ListHandler) resolveByWorkflowID(ctx context.Context, workflowID string) (string, error) { + spinner := ui.NewSpinner() + spinner.Start(fmt.Sprintf("Resolving workflow ID %q...", workflowID)) + rows, err := h.wdc.ListAll(ctx, workflowdataclient.DefaultPageSize) + spinner.Stop() + if err != nil { + return "", fmt.Errorf("resolving workflow ID %q: %w", workflowID, err) + } + + for _, r := range rows { + if strings.EqualFold(r.WorkflowID, workflowID) { + if r.UUID == "" { + return "", fmt.Errorf("workflow with ID %q found but has no platform UUID", workflowID) + } + return r.UUID, nil + } + } + return "", fmt.Errorf("no workflow found with ID %q", workflowID) +} - // Treat arg as a workflow name — look it up via the platform API. +// resolveByName finds the platform UUID for a workflow given its name. +func (h *ListHandler) resolveByName(ctx context.Context, name string) (string, error) { spinner := ui.NewSpinner() - spinner.Start(fmt.Sprintf("Resolving workflow %q...", arg)) - rows, err := h.wdc.SearchByName(ctx, arg, workflowdataclient.DefaultPageSize) + spinner.Start(fmt.Sprintf("Resolving workflow %q...", name)) + rows, err := h.wdc.SearchByName(ctx, name, workflowdataclient.DefaultPageSize) spinner.Stop() if err != nil { - return "", fmt.Errorf("resolving workflow name %q: %w", arg, err) + return "", fmt.Errorf("resolving workflow name %q: %w", name, err) } // Exact-name match only (SearchByName is a contains match on the server). var matches []workflowdataclient.Workflow for _, r := range rows { - if strings.EqualFold(strings.TrimSpace(r.Name), arg) { + if strings.EqualFold(strings.TrimSpace(r.Name), name) { matches = append(matches, r) } } if len(matches) == 0 { - return "", fmt.Errorf("no workflow found with name %q", arg) + return "", fmt.Errorf("no workflow found with name %q", name) } // Prefer an ACTIVE workflow when multiple versions exist. @@ -155,15 +180,15 @@ func (h *ListHandler) resolveWorkflowUUID(ctx context.Context, arg string) (stri return active[0].UUID, nil } if len(active) > 1 { - return "", fmt.Errorf("multiple ACTIVE workflows named %q found; provide the workflow UUID instead", arg) + return "", fmt.Errorf("multiple ACTIVE workflows named %q found; provide the workflow ID instead", name) } // No ACTIVE found — fall back to first match and warn. if !h.nonInteractive { - ui.Warning(fmt.Sprintf("No ACTIVE deployment for workflow %q; showing executions for the first match (status: %s)", arg, matches[0].Status)) + ui.Warning(fmt.Sprintf("No ACTIVE deployment for workflow %q; showing executions for the first match (status: %s)", name, matches[0].Status)) } if matches[0].UUID == "" { - return "", fmt.Errorf("workflow %q resolved but has no UUID; try providing the UUID directly", arg) + return "", fmt.Errorf("workflow %q resolved but has no platform UUID", name) } return matches[0].UUID, nil } @@ -215,16 +240,16 @@ func newList(runtimeContext *runtime.Context) *cobra.Command { var jsonFlag bool cmd := &cobra.Command{ - Use: "list [workflow-uuid-or-name]", + Use: "list [workflow-id-or-name]", Short: "List recent executions for a workflow", Long: `List workflow executions from the CRE platform. -The optional argument accepts either a workflow UUID or a workflow name. -When a name is given the CLI resolves it to the active deployment's UUID. -When omitted, executions across all workflows are returned.`, +The optional argument accepts either an on-chain Workflow ID (64-char hex, +visible in 'cre workflow list') or a workflow name. When omitted, executions +across all workflows are returned.`, Example: "cre workflow execution list\n" + " cre workflow execution list my-workflow\n" + - " cre workflow execution list 7f3d8a12-b1c2-4d3e-9f0a-1b2c3d4e5f6g\n" + + " cre workflow execution list 00da21b8b3e117e31f3a3e8a0795225cbde6c00283a84395117669691f2b7856\n" + " cre workflow execution list my-workflow --status FAILURE\n" + " cre workflow execution list my-workflow --start 2026-01-01T00:00:00Z --end 2026-01-02T00:00:00Z\n" + " cre workflow execution list my-workflow --limit 50 --output json", @@ -252,15 +277,14 @@ When omitted, executions across all workflows are returned.`, return cmd } -// looksLikeUUID returns true when s has the standard UUID shape (8-4-4-4-12). -func looksLikeUUID(s string) bool { - parts := strings.Split(s, "-") - if len(parts) != 5 { +// looksLikeWorkflowID returns true when s is a 64-character hex string, +// matching the on-chain WorkflowID format shown in `cre workflow list`. +func looksLikeWorkflowID(s string) bool { + if len(s) != 64 { return false } - lengths := []int{8, 4, 4, 4, 12} - for i, p := range parts { - if len(p) != lengths[i] { + for _, c := range s { + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { return false } } diff --git a/cmd/workflow/list/list.go b/cmd/workflow/list/list.go index eed93d49..e1b0dc5f 100644 --- a/cmd/workflow/list/list.go +++ b/cmd/workflow/list/list.go @@ -112,6 +112,17 @@ func (h *Handler) Execute(ctx context.Context, inputs Inputs) error { CountBeforeDeletedFilter: afterRegistryFilter, IncludeDeleted: inputs.IncludeDeleted, }) + + if len(rows) > 0 { + ui.Bold("Inspect executions:") + ui.Dim(" cre workflow execution list ") + ui.Dim(" cre workflow execution list --status FAILURE") + ui.Dim(" cre workflow execution status ") + ui.Dim(" cre workflow execution events ") + ui.Dim(" cre workflow execution logs ") + ui.Line() + } + return nil } From 1120366a8be22bf0c32af6ef1603cffd17feaab1 Mon Sep 17 00:00:00 2001 From: Emmanuel Jacquier Date: Mon, 1 Jun 2026 08:56:32 -0400 Subject: [PATCH 06/14] workflow list --json shorthand --- cmd/workflow/list/list.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cmd/workflow/list/list.go b/cmd/workflow/list/list.go index e1b0dc5f..473edbb3 100644 --- a/cmd/workflow/list/list.go +++ b/cmd/workflow/list/list.go @@ -131,6 +131,7 @@ func New(runtimeContext *runtime.Context) *cobra.Command { var registryID string var includeDeleted bool var outputFormat string + var jsonFlag bool cmd := &cobra.Command{ Use: "list", @@ -143,6 +144,9 @@ func New(runtimeContext *runtime.Context) *cobra.Command { " cre workflow list --output json > workflows.json", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { + if jsonFlag { + outputFormat = outputFormatJSON + } inputs, err := resolveInputs(registryID, includeDeleted, outputFormat) if err != nil { return err @@ -154,5 +158,6 @@ func New(runtimeContext *runtime.Context) *cobra.Command { cmd.Flags().StringVar(®istryID, "registry", "", "Filter by registry ID from user context") cmd.Flags().BoolVar(&includeDeleted, "include-deleted", false, "Include workflows in DELETED status") cmd.Flags().StringVar(&outputFormat, "output", "", `Output format: "json" prints a JSON array to stdout`) + cmd.Flags().BoolVar(&jsonFlag, "json", false, "Output as JSON (shorthand for --output=json)") return cmd } From d34b4fe203a922ed5d8220efad31d373a16ea297 Mon Sep 17 00:00:00 2001 From: Emmanuel Jacquier Date: Mon, 1 Jun 2026 11:45:16 -0400 Subject: [PATCH 07/14] updated status cmd and added cre workflow status cmd --- cmd/root.go | 1 + cmd/workflow/wfstatus/status.go | 214 ++++++++++++++++++ cmd/workflow/workflow.go | 2 + .../workflowdataclient/workflow_status.go | 198 ++++++++++++++++ internal/workflowrender/execution.go | 9 +- internal/workflowrender/workflow_status.go | 171 ++++++++++++++ 6 files changed, 587 insertions(+), 8 deletions(-) create mode 100644 cmd/workflow/wfstatus/status.go create mode 100644 internal/client/workflowdataclient/workflow_status.go create mode 100644 internal/workflowrender/workflow_status.go diff --git a/cmd/root.go b/cmd/root.go index 435f7e6a..b2e95a93 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -542,6 +542,7 @@ func isLoadSettings(cmd *cobra.Command) bool { "cre workflow execution status": {}, "cre workflow execution events": {}, "cre workflow execution logs": {}, + "cre workflow status": {}, "cre account": {}, "cre secrets": {}, "cre templates": {}, diff --git a/cmd/workflow/wfstatus/status.go b/cmd/workflow/wfstatus/status.go new file mode 100644 index 00000000..a977d178 --- /dev/null +++ b/cmd/workflow/wfstatus/status.go @@ -0,0 +1,214 @@ +// Package wfstatus implements the `cre workflow status` command. +// It is named wfstatus to avoid a collision with the Go standard library +// package name "status" in import paths. +package wfstatus + +import ( + "context" + "fmt" + "strings" + "sync" + "time" + + "github.com/spf13/cobra" + + "github.com/smartcontractkit/cre-cli/internal/client/graphqlclient" + "github.com/smartcontractkit/cre-cli/internal/client/workflowdataclient" + "github.com/smartcontractkit/cre-cli/internal/credentials" + "github.com/smartcontractkit/cre-cli/internal/runtime" + "github.com/smartcontractkit/cre-cli/internal/ui" + "github.com/smartcontractkit/cre-cli/internal/workflowrender" +) + +const outputFormatJSON = "json" + +// creditLookbackDays is the window passed to the billing service for credit aggregation. +const creditLookbackDays = 30 + +// Handler fetches and renders a comprehensive workflow status view. +type Handler struct { + credentials *credentials.Credentials + wdc *workflowdataclient.Client +} + +// NewHandler builds a Handler backed by a real WorkflowDataClient. +func NewHandler(ctx *runtime.Context) *Handler { + gql := graphqlclient.New(ctx.Credentials, ctx.EnvironmentSet, ctx.Logger) + wdc := workflowdataclient.New(gql, ctx.Logger) + return &Handler{credentials: ctx.Credentials, wdc: wdc} +} + +// NewHandlerWithClient builds a Handler with a pre-built client (for testing). +func NewHandlerWithClient(ctx *runtime.Context, wdc *workflowdataclient.Client) *Handler { + return &Handler{credentials: ctx.Credentials, wdc: wdc} +} + +// resolveUUID returns the platform UUID for a workflow name or on-chain WorkflowID. +func (h *Handler) resolveUUID(ctx context.Context, arg string) (string, error) { + if looksLikeWorkflowID(arg) { + return h.resolveByWorkflowID(ctx, arg) + } + return h.resolveByName(ctx, arg) +} + +func (h *Handler) resolveByWorkflowID(ctx context.Context, workflowID string) (string, error) { + spinner := ui.NewSpinner() + spinner.Start(fmt.Sprintf("Resolving workflow ID %q...", workflowID)) + rows, err := h.wdc.ListAll(ctx, workflowdataclient.DefaultPageSize) + spinner.Stop() + if err != nil { + return "", fmt.Errorf("resolving workflow ID %q: %w", workflowID, err) + } + for _, r := range rows { + if strings.EqualFold(r.WorkflowID, workflowID) { + if r.UUID == "" { + return "", fmt.Errorf("workflow with ID %q found but has no platform UUID", workflowID) + } + return r.UUID, nil + } + } + return "", fmt.Errorf("no workflow found with ID %q", workflowID) +} + +func (h *Handler) resolveByName(ctx context.Context, name string) (string, error) { + spinner := ui.NewSpinner() + spinner.Start(fmt.Sprintf("Resolving workflow %q...", name)) + rows, err := h.wdc.SearchByName(ctx, name, workflowdataclient.DefaultPageSize) + spinner.Stop() + if err != nil { + return "", fmt.Errorf("resolving workflow name %q: %w", name, err) + } + var matches []workflowdataclient.Workflow + for _, r := range rows { + if strings.EqualFold(strings.TrimSpace(r.Name), name) { + matches = append(matches, r) + } + } + if len(matches) == 0 { + return "", fmt.Errorf("no workflow found with name %q", name) + } + for _, r := range matches { + if strings.EqualFold(r.Status, "ACTIVE") { + return r.UUID, nil + } + } + return matches[0].UUID, nil +} + +// Execute fetches all status data in parallel and renders it. +func (h *Handler) Execute(ctx context.Context, arg, outputFormat string) error { + if h.credentials == nil { + return fmt.Errorf("credentials not available — run `cre login` and retry") + } + + uuid, err := h.resolveUUID(ctx, arg) + if err != nil { + return err + } + + spinner := ui.NewSpinner() + spinner.Start("Fetching workflow status...") + + from := time.Now().UTC().AddDate(0, 0, -creditLookbackDays) + + var ( + summary *workflowdataclient.WorkflowSummary + deployment *workflowdataclient.WorkflowDeploymentRecord + executions []workflowdataclient.Execution + summaryErr, deployErr, execErr error + wg sync.WaitGroup + ) + + wg.Add(3) + go func() { + defer wg.Done() + summary, summaryErr = h.wdc.GetWorkflowSummary(ctx, uuid, from) + }() + go func() { + defer wg.Done() + deployment, deployErr = h.wdc.GetLatestDeployment(ctx, uuid) + }() + go func() { + defer wg.Done() + executions, execErr = h.wdc.ListExecutions(ctx, workflowdataclient.ListExecutionsInput{ + WorkflowUUID: &uuid, + Limit: 1, + }) + }() + wg.Wait() + spinner.Stop() + + if summaryErr != nil { + return summaryErr + } + if execErr != nil { + return execErr + } + // deployErr is non-fatal: the deployment query may be restricted for some users. + if deployErr != nil { + deployment = nil + } + + var lastExec *workflowdataclient.Execution + if len(executions) > 0 { + lastExec = &executions[0] + } + + view := workflowrender.WorkflowStatusView{ + Summary: summary, + Deployment: deployment, + LastExecution: lastExec, + } + + if outputFormat == outputFormatJSON { + return workflowrender.PrintWorkflowStatusJSON(view) + } + workflowrender.PrintWorkflowStatusTable(view) + return nil +} + +// New returns the cobra command. +func New(runtimeContext *runtime.Context) *cobra.Command { + var outputFormat string + var jsonFlag bool + + cmd := &cobra.Command{ + Use: "status ", + Short: "Show deployment health and execution summary for a workflow", + Long: `Show the full health picture of a workflow: deployment status, activation +state, execution success/failure counts, and the most recent execution. + +Useful for diagnosing the gap between registering a workflow and it +becoming active in the DON, or for a quick health check.`, + Example: "cre workflow status my-workflow\n" + + " cre workflow status 00da21b8b3e117e31f3a3e8a0795225cbde6c00283a84395117669691f2b7856\n" + + " cre workflow status my-workflow --output json", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if jsonFlag { + outputFormat = outputFormatJSON + } + if outputFormat != "" && outputFormat != outputFormatJSON { + return fmt.Errorf("--output %q is not supported; only %q is accepted", outputFormat, outputFormatJSON) + } + return NewHandler(runtimeContext).Execute(cmd.Context(), args[0], outputFormat) + }, + } + + cmd.Flags().StringVar(&outputFormat, "output", "", `Output format: "json" prints JSON to stdout`) + cmd.Flags().BoolVar(&jsonFlag, "json", false, "Output as JSON (shorthand for --output=json)") + return cmd +} + +// looksLikeWorkflowID returns true for 64-char hex strings (on-chain WorkflowID). +func looksLikeWorkflowID(s string) bool { + if len(s) != 64 { + return false + } + for _, c := range s { + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { + return false + } + } + return true +} diff --git a/cmd/workflow/workflow.go b/cmd/workflow/workflow.go index 8af5ec20..7248a904 100644 --- a/cmd/workflow/workflow.go +++ b/cmd/workflow/workflow.go @@ -10,6 +10,7 @@ import ( "github.com/smartcontractkit/cre-cli/cmd/workflow/deploy" "github.com/smartcontractkit/cre-cli/cmd/workflow/execution" workflowget "github.com/smartcontractkit/cre-cli/cmd/workflow/get" + "github.com/smartcontractkit/cre-cli/cmd/workflow/wfstatus" "github.com/smartcontractkit/cre-cli/cmd/workflow/hash" "github.com/smartcontractkit/cre-cli/cmd/workflow/limits" workflowlist "github.com/smartcontractkit/cre-cli/cmd/workflow/list" @@ -30,6 +31,7 @@ func New(runtimeContext *runtime.Context) *cobra.Command { workflowCmd.AddCommand(supported_chains.New(runtimeContext)) workflowCmd.AddCommand(activate.New(runtimeContext)) workflowCmd.AddCommand(execution.New(runtimeContext)) + workflowCmd.AddCommand(wfstatus.New(runtimeContext)) workflowCmd.AddCommand(build.New(runtimeContext)) workflowCmd.AddCommand(convert.New(runtimeContext)) workflowCmd.AddCommand(delete.New(runtimeContext)) diff --git a/internal/client/workflowdataclient/workflow_status.go b/internal/client/workflowdataclient/workflow_status.go new file mode 100644 index 00000000..25fe8d83 --- /dev/null +++ b/internal/client/workflowdataclient/workflow_status.go @@ -0,0 +1,198 @@ +package workflowdataclient + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/machinebox/graphql" +) + +// WorkflowSummary is an extended workflow record including execution health fields. +type WorkflowSummary struct { + UUID string + Name string + WorkflowID string + OwnerAddress string + Status string + WorkflowSource string + RegisteredAt time.Time + ExecutedAt *time.Time + ExecutionCount int + SuccessCount int + FailureCount int +} + +// WorkflowDeploymentRecord is a single deployment entry. +type WorkflowDeploymentRecord struct { + UUID string + WorkflowID string + Status string + DeployedAt time.Time + TxHash *string + BinaryURL *string + ConfigURL *string + ErrorMessage *string +} + +// ---- queries ---- + +const getWorkflowSummaryQuery = ` +query GetWorkflowSummary($input: WorkflowsInput!) { + workflows(input: $input) { + data { + uuid + name + workflowId + ownerAddress + status + workflowSource + registeredAt + executedAt + executionCount + executionCountByStatus { + success + failure + } + } + } +} +` + +const getLatestDeploymentQuery = ` +query GetLatestDeployment($input: WorkflowDeploymentsInput!) { + workflowDeployments(input: $input) { + data { + uuid + workflowID + status + deployedAt + txHash + binaryURL + configURL + errorMessage + } + } +} +` + +// ---- envelopes ---- + +type gqlWorkflowSummary struct { + UUID string `json:"uuid"` + Name string `json:"name"` + WorkflowID string `json:"workflowId"` + OwnerAddress string `json:"ownerAddress"` + Status string `json:"status"` + WorkflowSource string `json:"workflowSource"` + RegisteredAt time.Time `json:"registeredAt"` + ExecutedAt *time.Time `json:"executedAt"` + ExecutionCount int `json:"executionCount"` + ExecutionCountByStatus struct { + Success int `json:"success"` + Failure int `json:"failure"` + } `json:"executionCountByStatus"` +} + +type getWorkflowSummaryEnvelope struct { + Workflows struct { + Data []gqlWorkflowSummary `json:"data"` + } `json:"workflows"` +} + +type gqlDeploymentRecord struct { + UUID string `json:"uuid"` + WorkflowID string `json:"workflowID"` + Status string `json:"status"` + DeployedAt time.Time `json:"deployedAt"` + TxHash *string `json:"txHash"` + BinaryURL *string `json:"binaryURL"` + ConfigURL *string `json:"configURL"` + ErrorMessage *string `json:"errorMessage"` +} + +type getLatestDeploymentEnvelope struct { + WorkflowDeployments struct { + Data []gqlDeploymentRecord `json:"data"` + } `json:"workflowDeployments"` +} + +// ---- methods ---- + +// GetWorkflowSummary fetches extended workflow details including execution health. +// uuid is the platform UUID used to match the correct workflow from the list. +func (c *Client) GetWorkflowSummary(parent context.Context, uuid string, _ time.Time) (*WorkflowSummary, error) { + ctx, cancel := c.CreateServiceContextWithTimeout(parent) + defer cancel() + + req := graphql.NewRequest(getWorkflowSummaryQuery) + req.Var("input", map[string]any{ + "page": map[string]any{"number": 0, "size": DefaultPageSize}, + }) + + var env getWorkflowSummaryEnvelope + if err := c.graphql.Execute(ctx, req, &env); err != nil { + return nil, fmt.Errorf("get workflow summary: %w", err) + } + + for _, g := range env.Workflows.Data { + if !strings.EqualFold(g.UUID, uuid) { + continue + } + return &WorkflowSummary{ + UUID: g.UUID, + Name: g.Name, + WorkflowID: g.WorkflowID, + OwnerAddress: g.OwnerAddress, + Status: g.Status, + WorkflowSource: g.WorkflowSource, + RegisteredAt: g.RegisteredAt, + ExecutedAt: g.ExecutedAt, + ExecutionCount: g.ExecutionCount, + SuccessCount: g.ExecutionCountByStatus.Success, + FailureCount: g.ExecutionCountByStatus.Failure, + }, nil + } + return nil, fmt.Errorf("workflow %q not found", uuid) +} + +// GetLatestDeployment fetches the most recent deployment record for a workflow. +func (c *Client) GetLatestDeployment(parent context.Context, workflowUUID string) (*WorkflowDeploymentRecord, error) { + ctx, cancel := c.CreateServiceContextWithTimeout(parent) + defer cancel() + + req := graphql.NewRequest(getLatestDeploymentQuery) + req.Var("input", map[string]any{ + "workflowUUID": workflowUUID, + "orderBy": map[string]any{ + "field": "DEPLOYED_AT", + "order": "DESC", + }, + "page": map[string]any{ + "number": 0, + "size": 1, + }, + }) + + var env getLatestDeploymentEnvelope + if err := c.graphql.Execute(ctx, req, &env); err != nil { + return nil, fmt.Errorf("get latest deployment: %w", err) + } + + if len(env.WorkflowDeployments.Data) == 0 { + return nil, nil //nolint:nilnil // no deployment record is a valid state + } + + g := env.WorkflowDeployments.Data[0] + return &WorkflowDeploymentRecord{ + UUID: g.UUID, + WorkflowID: g.WorkflowID, + Status: g.Status, + DeployedAt: g.DeployedAt, + TxHash: g.TxHash, + BinaryURL: g.BinaryURL, + ConfigURL: g.ConfigURL, + ErrorMessage: g.ErrorMessage, + }, nil +} diff --git a/internal/workflowrender/execution.go b/internal/workflowrender/execution.go index 18f2d513..cf14e968 100644 --- a/internal/workflowrender/execution.go +++ b/internal/workflowrender/execution.go @@ -76,10 +76,7 @@ func PrintExecutionsTable(rows []workflowdataclient.Execution) { if e.FinishedAt != nil { ui.Dim(fmt.Sprintf(" Finished: %s (%s)", e.FinishedAt.UTC().Format("2006-01-02 15:04:05"), formatDuration(e.FinishedAt.Sub(e.StartedAt)))) } - if e.CreditUsed != nil { - ui.Dim(fmt.Sprintf(" Credits: %s", *e.CreditUsed)) - } - ui.Line() + ui.Line() } } @@ -131,10 +128,6 @@ func PrintExecutionDetailTable(e workflowdataclient.Execution) { } ui.Dim(fmt.Sprintf(" Time: %s", timeStr)) - if e.CreditUsed != nil { - ui.Dim(fmt.Sprintf(" Credits: %s", *e.CreditUsed)) - } - if len(e.Errors) > 0 { ui.Line() ui.Bold("Top-Level Errors:") diff --git a/internal/workflowrender/workflow_status.go b/internal/workflowrender/workflow_status.go new file mode 100644 index 00000000..6c8b5137 --- /dev/null +++ b/internal/workflowrender/workflow_status.go @@ -0,0 +1,171 @@ +package workflowrender + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/smartcontractkit/cre-cli/internal/client/workflowdataclient" + "github.com/smartcontractkit/cre-cli/internal/ui" +) + +// WorkflowStatusView bundles all data for the status command output. +type WorkflowStatusView struct { + Summary *workflowdataclient.WorkflowSummary + Deployment *workflowdataclient.WorkflowDeploymentRecord + LastExecution *workflowdataclient.Execution +} + +// PrintWorkflowStatusTable renders a rich workflow status view to stdout. +func PrintWorkflowStatusTable(v WorkflowStatusView) { + s := v.Summary + ui.Line() + ui.Bold(fmt.Sprintf("Workflow: %s", s.Name)) + ui.Dim(fmt.Sprintf(" Workflow ID: %s", s.WorkflowID)) + ui.Dim(fmt.Sprintf(" Status: %s%s", s.Status, deploymentStatusHint(s.Status))) + ui.Dim(fmt.Sprintf(" Registered: %s", s.RegisteredAt.UTC().Format("2006-01-02 15:04:05"))) + + if s.ExecutedAt != nil { + ui.Dim(fmt.Sprintf(" Last executed: %s", s.ExecutedAt.UTC().Format("2006-01-02 15:04:05"))) + if s.Status == "PENDING" { + gap := s.ExecutedAt.Sub(s.RegisteredAt) + ui.Dim(fmt.Sprintf(" Activation gap: %s", formatDuration(gap))) + } + } else { + ui.Dim(" Last executed: never") + if s.Status == "PENDING" { + gap := time.Since(s.RegisteredAt) + ui.Dim(fmt.Sprintf(" Pending for: %s", formatDuration(gap))) + } + } + + + + ui.Line() + ui.Bold("Deployment") + if v.Deployment != nil { + d := v.Deployment + ui.Dim(fmt.Sprintf(" Status: %s", d.Status)) + ui.Dim(fmt.Sprintf(" Deployed at: %s", d.DeployedAt.UTC().Format("2006-01-02 15:04:05"))) + if d.TxHash != nil && *d.TxHash != "" { + ui.Dim(fmt.Sprintf(" Tx hash: %s", *d.TxHash)) + } + if d.BinaryURL != nil && *d.BinaryURL != "" { + ui.Dim(fmt.Sprintf(" Binary URL: %s", *d.BinaryURL)) + } + if d.ErrorMessage != nil && *d.ErrorMessage != "" { + ui.Dim(fmt.Sprintf(" Error: %s", *d.ErrorMessage)) + } + } else { + ui.Dim(" No deployment record found") + } + + ui.Line() + ui.Bold("Execution summary") + ui.Dim(fmt.Sprintf(" Total: %d", s.ExecutionCount)) + ui.Dim(fmt.Sprintf(" Success: %d", s.SuccessCount)) + ui.Dim(fmt.Sprintf(" Failure: %d", s.FailureCount)) + + if v.LastExecution != nil { + e := v.LastExecution + ui.Line() + ui.Bold("Last execution") + ui.Dim(fmt.Sprintf(" ID: %s", e.UUID)) + ui.Dim(fmt.Sprintf(" Status: %s", e.Status)) + ui.Dim(fmt.Sprintf(" Started: %s", e.StartedAt.UTC().Format("2006-01-02 15:04:05"))) + if e.FinishedAt != nil { + ui.Dim(fmt.Sprintf(" Duration: %s", formatDuration(e.FinishedAt.Sub(e.StartedAt)))) + } + if len(e.Errors) > 0 { + ui.Dim(" Errors:") + for _, err := range e.Errors { + ui.Dim(fmt.Sprintf(" - %s (x%d)", err.Error, err.Count)) + } + } + ui.Line() + ui.Bold("Next steps:") + ui.Dim(fmt.Sprintf(" cre workflow execution list %s", s.Name)) + ui.Dim(fmt.Sprintf(" cre workflow execution events %s", e.UUID)) + ui.Dim(fmt.Sprintf(" cre workflow execution logs %s", e.UUID)) + } else if s.Status == "PENDING" { + ui.Line() + ui.Dim(" Workflow has not executed yet — it may still be activating in the DON.") + } + + ui.Line() +} + +// PrintWorkflowStatusJSON marshals the status view as indented JSON to stdout. +func PrintWorkflowStatusJSON(v WorkflowStatusView) error { + s := v.Summary + out := map[string]any{ + "workflow": map[string]any{ + "name": s.Name, + "workflowId": s.WorkflowID, + "status": s.Status, + "registeredAt": s.RegisteredAt.UTC().Format(time.RFC3339), + "lastExecutedAt": timeOrNil(s.ExecutedAt), + "executionCount": s.ExecutionCount, + "successCount": s.SuccessCount, + "failureCount": s.FailureCount, + }, + } + + if v.Deployment != nil { + d := v.Deployment + dep := map[string]any{ + "status": d.Status, + "deployedAt": d.DeployedAt.UTC().Format(time.RFC3339), + "txHash": d.TxHash, + "binaryURL": d.BinaryURL, + } + if d.ErrorMessage != nil && *d.ErrorMessage != "" { + dep["errorMessage"] = *d.ErrorMessage + } + out["deployment"] = dep + } + + if v.LastExecution != nil { + e := v.LastExecution + errs := make([]map[string]any, 0, len(e.Errors)) + for _, err := range e.Errors { + errs = append(errs, map[string]any{"error": err.Error, "count": err.Count}) + } + out["lastExecution"] = map[string]any{ + "uuid": e.UUID, + "status": string(e.Status), + "startedAt": e.StartedAt.UTC().Format(time.RFC3339), + "finishedAt": timeOrNil(e.FinishedAt), + "errors": errs, + } + } + + data, err := json.MarshalIndent(out, "", " ") + if err != nil { + return err + } + fmt.Println(string(data)) + return nil +} + +// deploymentStatusHint returns an inline warning for non-healthy states. +func deploymentStatusHint(status string) string { + switch strings.ToUpper(status) { + case "PENDING": + return " ⚠ not yet active in the DON" + case "FAILED": + return " ✗ activation failed" + case "PAUSED": + return " — paused" + default: + return "" + } +} + +func timeOrNil(t *time.Time) any { + if t == nil { + return nil + } + return t.UTC().Format(time.RFC3339) +} From 329b92525f48631b6e18a6cac36e3b40f8158804 Mon Sep 17 00:00:00 2001 From: Emmanuel Jacquier Date: Mon, 1 Jun 2026 11:47:15 -0400 Subject: [PATCH 08/14] gendocs --- docs/cre_workflow.md | 2 + docs/cre_workflow_execution.md | 34 +++++++++++++++++ docs/cre_workflow_execution_events.md | 48 +++++++++++++++++++++++ docs/cre_workflow_execution_list.md | 55 +++++++++++++++++++++++++++ docs/cre_workflow_execution_logs.md | 46 ++++++++++++++++++++++ docs/cre_workflow_execution_status.md | 44 +++++++++++++++++++++ docs/cre_workflow_list.md | 1 + docs/cre_workflow_status.md | 48 +++++++++++++++++++++++ 8 files changed, 278 insertions(+) create mode 100644 docs/cre_workflow_execution.md create mode 100644 docs/cre_workflow_execution_events.md create mode 100644 docs/cre_workflow_execution_list.md create mode 100644 docs/cre_workflow_execution_logs.md create mode 100644 docs/cre_workflow_execution_status.md create mode 100644 docs/cre_workflow_status.md diff --git a/docs/cre_workflow.md b/docs/cre_workflow.md index 25797b8a..5323d078 100644 --- a/docs/cre_workflow.md +++ b/docs/cre_workflow.md @@ -36,11 +36,13 @@ cre workflow [optional flags] * [cre workflow custom-build](cre_workflow_custom-build.md) - Converts an existing workflow to a custom (self-compiled) build * [cre workflow delete](cre_workflow_delete.md) - Deletes all versions of a workflow from the Workflow Registry * [cre workflow deploy](cre_workflow_deploy.md) - Deploys a workflow to the Workflow Registry contract +* [cre workflow execution](cre_workflow_execution.md) - Query workflow execution history * [cre workflow get](cre_workflow_get.md) - Shows metadata for the workflow configured in workflow.yaml * [cre workflow hash](cre_workflow_hash.md) - Computes and displays workflow hashes * [cre workflow limits](cre_workflow_limits.md) - Manage simulation limits * [cre workflow list](cre_workflow_list.md) - Lists workflows deployed for your organization * [cre workflow pause](cre_workflow_pause.md) - Pauses workflow on the Workflow Registry contract * [cre workflow simulate](cre_workflow_simulate.md) - Simulates a workflow +* [cre workflow status](cre_workflow_status.md) - Show deployment health and execution summary for a workflow * [cre workflow supported-chains](cre_workflow_supported-chains.md) - List chains and mock forwarder addresses for your tenant diff --git a/docs/cre_workflow_execution.md b/docs/cre_workflow_execution.md new file mode 100644 index 00000000..3b4738fe --- /dev/null +++ b/docs/cre_workflow_execution.md @@ -0,0 +1,34 @@ +## cre workflow execution + +Query workflow execution history + +### Synopsis + +The execution command provides visibility into workflow executions, node events, and logs. + +### Options + +``` + -h, --help help for execution +``` + +### Options inherited from parent commands + +``` + --allow-unknown-chains Skip chain-name validation against the chain-selectors registry (for experimental chains) + -e, --env string Path to .env file which contains sensitive info + --non-interactive Fail instead of prompting; requires all inputs via flags + -R, --project-root string Path to the project root + -E, --public-env string Path to .env.public file which contains shared, non-sensitive build config + -T, --target string Use target settings from YAML config + -v, --verbose Run command in VERBOSE mode +``` + +### SEE ALSO + +* [cre workflow](cre_workflow.md) - Manages workflows +* [cre workflow execution events](cre_workflow_execution_events.md) - Show the node/capability event timeline for an execution +* [cre workflow execution list](cre_workflow_execution_list.md) - List recent executions for a workflow +* [cre workflow execution logs](cre_workflow_execution_logs.md) - Show logs emitted during a workflow execution +* [cre workflow execution status](cre_workflow_execution_status.md) - Show detailed status of a single execution + diff --git a/docs/cre_workflow_execution_events.md b/docs/cre_workflow_execution_events.md new file mode 100644 index 00000000..dd43e7ea --- /dev/null +++ b/docs/cre_workflow_execution_events.md @@ -0,0 +1,48 @@ +## cre workflow execution events + +Show the node/capability event timeline for an execution + +### Synopsis + +Fetch and display the ordered sequence of capability events for a workflow +execution, including per-event status, method, duration, and any errors. + +``` +cre workflow execution events [optional flags] +``` + +### Examples + +``` +cre workflow execution events 7f3d8a12-b1c2-4d3e-9f0a-1b2c3d4e5f6g + cre workflow execution events 7f3d8a12-b1c2-4d3e-9f0a-1b2c3d4e5f6g --capability fetch-price + cre workflow execution events 7f3d8a12-b1c2-4d3e-9f0a-1b2c3d4e5f6g --status FAILURE + cre workflow execution events 7f3d8a12-b1c2-4d3e-9f0a-1b2c3d4e5f6g --output json +``` + +### Options + +``` + --capability string Filter events to a specific capability ID + -h, --help help for events + --json Output as JSON (shorthand for --output=json) + --output string Output format: "json" prints a JSON array to stdout + --status string Filter events by status (e.g. FAILURE) +``` + +### Options inherited from parent commands + +``` + --allow-unknown-chains Skip chain-name validation against the chain-selectors registry (for experimental chains) + -e, --env string Path to .env file which contains sensitive info + --non-interactive Fail instead of prompting; requires all inputs via flags + -R, --project-root string Path to the project root + -E, --public-env string Path to .env.public file which contains shared, non-sensitive build config + -T, --target string Use target settings from YAML config + -v, --verbose Run command in VERBOSE mode +``` + +### SEE ALSO + +* [cre workflow execution](cre_workflow_execution.md) - Query workflow execution history + diff --git a/docs/cre_workflow_execution_list.md b/docs/cre_workflow_execution_list.md new file mode 100644 index 00000000..b09f2e46 --- /dev/null +++ b/docs/cre_workflow_execution_list.md @@ -0,0 +1,55 @@ +## cre workflow execution list + +List recent executions for a workflow + +### Synopsis + +List workflow executions from the CRE platform. + +The optional argument accepts either an on-chain Workflow ID (64-char hex, +visible in 'cre workflow list') or a workflow name. When omitted, executions +across all workflows are returned. + +``` +cre workflow execution list [workflow-id-or-name] [flags] +``` + +### Examples + +``` +cre workflow execution list + cre workflow execution list my-workflow + cre workflow execution list 00da21b8b3e117e31f3a3e8a0795225cbde6c00283a84395117669691f2b7856 + cre workflow execution list my-workflow --status FAILURE + cre workflow execution list my-workflow --start 2026-01-01T00:00:00Z --end 2026-01-02T00:00:00Z + cre workflow execution list my-workflow --limit 50 --output json +``` + +### Options + +``` + --end string End of time range in ISO8601 format (e.g. 2026-01-02T00:00:00Z) + -h, --help help for list + --json Output as JSON (shorthand for --output=json) + --limit int Maximum number of executions to return (max 100) (default 20) + --output string Output format: "json" prints a JSON array to stdout + --start string Start of time range in ISO8601 format (e.g. 2026-01-01T00:00:00Z) + --status string Filter by execution status (TRIGGERED, IN_PROGRESS, SUCCESS, FAILURE) +``` + +### Options inherited from parent commands + +``` + --allow-unknown-chains Skip chain-name validation against the chain-selectors registry (for experimental chains) + -e, --env string Path to .env file which contains sensitive info + --non-interactive Fail instead of prompting; requires all inputs via flags + -R, --project-root string Path to the project root + -E, --public-env string Path to .env.public file which contains shared, non-sensitive build config + -T, --target string Use target settings from YAML config + -v, --verbose Run command in VERBOSE mode +``` + +### SEE ALSO + +* [cre workflow execution](cre_workflow_execution.md) - Query workflow execution history + diff --git a/docs/cre_workflow_execution_logs.md b/docs/cre_workflow_execution_logs.md new file mode 100644 index 00000000..17d0c2d9 --- /dev/null +++ b/docs/cre_workflow_execution_logs.md @@ -0,0 +1,46 @@ +## cre workflow execution logs + +Show logs emitted during a workflow execution + +### Synopsis + +Fetch and display all log lines emitted during a workflow execution. +Use --node to filter to a specific capability node (client-side filter). + +``` +cre workflow execution logs [optional flags] +``` + +### Examples + +``` +cre workflow execution logs 7f3d8a12-b1c2-4d3e-9f0a-1b2c3d4e5f6g + cre workflow execution logs 7f3d8a12-b1c2-4d3e-9f0a-1b2c3d4e5f6g --node ProcessData + cre workflow execution logs 7f3d8a12-b1c2-4d3e-9f0a-1b2c3d4e5f6g --output json +``` + +### Options + +``` + -h, --help help for logs + --json Output as JSON (shorthand for --output=json) + --node string Filter logs to a specific node/capability ID (case-insensitive) + --output string Output format: "json" prints a JSON array to stdout +``` + +### Options inherited from parent commands + +``` + --allow-unknown-chains Skip chain-name validation against the chain-selectors registry (for experimental chains) + -e, --env string Path to .env file which contains sensitive info + --non-interactive Fail instead of prompting; requires all inputs via flags + -R, --project-root string Path to the project root + -E, --public-env string Path to .env.public file which contains shared, non-sensitive build config + -T, --target string Use target settings from YAML config + -v, --verbose Run command in VERBOSE mode +``` + +### SEE ALSO + +* [cre workflow execution](cre_workflow_execution.md) - Query workflow execution history + diff --git a/docs/cre_workflow_execution_status.md b/docs/cre_workflow_execution_status.md new file mode 100644 index 00000000..2ab32483 --- /dev/null +++ b/docs/cre_workflow_execution_status.md @@ -0,0 +1,44 @@ +## cre workflow execution status + +Show detailed status of a single execution + +### Synopsis + +Fetch and display the full status of a workflow execution, including +top-level errors when the execution has failed. + +``` +cre workflow execution status [optional flags] +``` + +### Examples + +``` +cre workflow execution status 7f3d8a12-b1c2-4d3e-9f0a-1b2c3d4e5f6g + cre workflow execution status 7f3d8a12-b1c2-4d3e-9f0a-1b2c3d4e5f6g --output json +``` + +### Options + +``` + -h, --help help for status + --json Output as JSON (shorthand for --output=json) + --output string Output format: "json" prints JSON to stdout +``` + +### Options inherited from parent commands + +``` + --allow-unknown-chains Skip chain-name validation against the chain-selectors registry (for experimental chains) + -e, --env string Path to .env file which contains sensitive info + --non-interactive Fail instead of prompting; requires all inputs via flags + -R, --project-root string Path to the project root + -E, --public-env string Path to .env.public file which contains shared, non-sensitive build config + -T, --target string Use target settings from YAML config + -v, --verbose Run command in VERBOSE mode +``` + +### SEE ALSO + +* [cre workflow execution](cre_workflow_execution.md) - Query workflow execution history + diff --git a/docs/cre_workflow_list.md b/docs/cre_workflow_list.md index 88d78e6e..90392cc2 100644 --- a/docs/cre_workflow_list.md +++ b/docs/cre_workflow_list.md @@ -25,6 +25,7 @@ cre workflow list ``` -h, --help help for list --include-deleted Include workflows in DELETED status + --json Output as JSON (shorthand for --output=json) --output string Output format: "json" prints a JSON array to stdout --registry string Filter by registry ID from user context ``` diff --git a/docs/cre_workflow_status.md b/docs/cre_workflow_status.md new file mode 100644 index 00000000..fbb4ec80 --- /dev/null +++ b/docs/cre_workflow_status.md @@ -0,0 +1,48 @@ +## cre workflow status + +Show deployment health and execution summary for a workflow + +### Synopsis + +Show the full health picture of a workflow: deployment status, activation +state, execution success/failure counts, and the most recent execution. + +Useful for diagnosing the gap between registering a workflow and it +becoming active in the DON, or for a quick health check. + +``` +cre workflow status [optional flags] +``` + +### Examples + +``` +cre workflow status my-workflow + cre workflow status 00da21b8b3e117e31f3a3e8a0795225cbde6c00283a84395117669691f2b7856 + cre workflow status my-workflow --output json +``` + +### Options + +``` + -h, --help help for status + --json Output as JSON (shorthand for --output=json) + --output string Output format: "json" prints JSON to stdout +``` + +### Options inherited from parent commands + +``` + --allow-unknown-chains Skip chain-name validation against the chain-selectors registry (for experimental chains) + -e, --env string Path to .env file which contains sensitive info + --non-interactive Fail instead of prompting; requires all inputs via flags + -R, --project-root string Path to the project root + -E, --public-env string Path to .env.public file which contains shared, non-sensitive build config + -T, --target string Use target settings from YAML config + -v, --verbose Run command in VERBOSE mode +``` + +### SEE ALSO + +* [cre workflow](cre_workflow.md) - Manages workflows + From 05a4bc2b60d0257bdcdec7fd7fe6bde5146aa400 Mon Sep 17 00:00:00 2001 From: Emmanuel Jacquier Date: Mon, 1 Jun 2026 12:13:59 -0400 Subject: [PATCH 09/14] linter & remove credits --- cmd/workflow/execution/list.go | 2 +- cmd/workflow/wfstatus/status.go | 15 ++++++--------- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/cmd/workflow/execution/list.go b/cmd/workflow/execution/list.go index 6df29fba..8143f57d 100644 --- a/cmd/workflow/execution/list.go +++ b/cmd/workflow/execution/list.go @@ -284,7 +284,7 @@ func looksLikeWorkflowID(s string) bool { return false } for _, c := range s { - if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { + if (c < '0' || c > '9') && (c < 'a' || c > 'f') && (c < 'A' || c > 'F') { return false } } diff --git a/cmd/workflow/wfstatus/status.go b/cmd/workflow/wfstatus/status.go index a977d178..e5681cfc 100644 --- a/cmd/workflow/wfstatus/status.go +++ b/cmd/workflow/wfstatus/status.go @@ -22,9 +22,6 @@ import ( const outputFormatJSON = "json" -// creditLookbackDays is the window passed to the billing service for credit aggregation. -const creditLookbackDays = 30 - // Handler fetches and renders a comprehensive workflow status view. type Handler struct { credentials *credentials.Credentials @@ -109,14 +106,14 @@ func (h *Handler) Execute(ctx context.Context, arg, outputFormat string) error { spinner := ui.NewSpinner() spinner.Start("Fetching workflow status...") - from := time.Now().UTC().AddDate(0, 0, -creditLookbackDays) + from := time.Now().UTC().AddDate(0, 0, -30) var ( - summary *workflowdataclient.WorkflowSummary - deployment *workflowdataclient.WorkflowDeploymentRecord - executions []workflowdataclient.Execution + summary *workflowdataclient.WorkflowSummary + deployment *workflowdataclient.WorkflowDeploymentRecord + executions []workflowdataclient.Execution summaryErr, deployErr, execErr error - wg sync.WaitGroup + wg sync.WaitGroup ) wg.Add(3) @@ -206,7 +203,7 @@ func looksLikeWorkflowID(s string) bool { return false } for _, c := range s { - if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { + if (c < '0' || c > '9') && (c < 'a' || c > 'f') && (c < 'A' || c > 'F') { return false } } From d1dad0b19ec58ebc110a163a2112f568822cac55 Mon Sep 17 00:00:00 2001 From: Emmanuel Jacquier Date: Mon, 1 Jun 2026 13:01:38 -0400 Subject: [PATCH 10/14] fix linter --- cmd/workflow/wfstatus/status.go | 32 +++++++++++++---- .../client/workflowdataclient/execution.go | 28 +++++++++++++++ .../workflowdataclient/workflow_status.go | 34 +++++++++---------- internal/workflowrender/execution.go | 4 +-- internal/workflowrender/workflow_status.go | 4 +-- 5 files changed, 73 insertions(+), 29 deletions(-) diff --git a/cmd/workflow/wfstatus/status.go b/cmd/workflow/wfstatus/status.go index e5681cfc..79cb6529 100644 --- a/cmd/workflow/wfstatus/status.go +++ b/cmd/workflow/wfstatus/status.go @@ -109,14 +109,15 @@ func (h *Handler) Execute(ctx context.Context, arg, outputFormat string) error { from := time.Now().UTC().AddDate(0, 0, -30) var ( - summary *workflowdataclient.WorkflowSummary - deployment *workflowdataclient.WorkflowDeploymentRecord - executions []workflowdataclient.Execution - summaryErr, deployErr, execErr error - wg sync.WaitGroup + summary *workflowdataclient.WorkflowSummary + deployment *workflowdataclient.WorkflowDeploymentRecord + executions []workflowdataclient.Execution + successCount, failureCount int + summaryErr, deployErr, execErr, succErr, failErr error + wg sync.WaitGroup ) - wg.Add(3) + wg.Add(5) go func() { defer wg.Done() summary, summaryErr = h.wdc.GetWorkflowSummary(ctx, uuid, from) @@ -132,6 +133,14 @@ func (h *Handler) Execute(ctx context.Context, arg, outputFormat string) error { Limit: 1, }) }() + go func() { + defer wg.Done() + successCount, succErr = h.wdc.CountExecutions(ctx, uuid, []workflowdataclient.ExecutionStatus{workflowdataclient.ExecutionStatusSuccess}) + }() + go func() { + defer wg.Done() + failureCount, failErr = h.wdc.CountExecutions(ctx, uuid, []workflowdataclient.ExecutionStatus{workflowdataclient.ExecutionStatusFailure}) + }() wg.Wait() spinner.Stop() @@ -141,10 +150,19 @@ func (h *Handler) Execute(ctx context.Context, arg, outputFormat string) error { if execErr != nil { return execErr } - // deployErr is non-fatal: the deployment query may be restricted for some users. + // deployErr, succErr, failErr are non-fatal — degrade gracefully. if deployErr != nil { deployment = nil } + if succErr != nil { + successCount = 0 + } + if failErr != nil { + failureCount = 0 + } + summary.SuccessCount = successCount + summary.FailureCount = failureCount + summary.ExecutionCount = successCount + failureCount var lastExec *workflowdataclient.Execution if len(executions) > 0 { diff --git a/internal/client/workflowdataclient/execution.go b/internal/client/workflowdataclient/execution.go index 4d442942..62a7185b 100644 --- a/internal/client/workflowdataclient/execution.go +++ b/internal/client/workflowdataclient/execution.go @@ -272,6 +272,34 @@ func (c *Client) ListExecutions(parent context.Context, in ListExecutionsInput) return toExecutions(env.WorkflowExecutions.Data), nil } +// CountExecutions returns the total number of executions matching the given filters. +// It fetches only a single-item page — only the count field is used. +func (c *Client) CountExecutions(parent context.Context, workflowUUID string, statuses []ExecutionStatus) (int, error) { + ctx, cancel := c.CreateServiceContextWithTimeout(parent) + defer cancel() + + input := map[string]any{ + "workflowUuid": workflowUUID, + "page": map[string]any{"number": 0, "size": 1}, + } + if len(statuses) > 0 { + ss := make([]string, len(statuses)) + for i, s := range statuses { + ss[i] = string(s) + } + input["status"] = ss + } + + req := graphql.NewRequest(listExecutionsQuery) + req.Var("input", input) + + var env listExecutionsEnvelope + if err := c.graphql.Execute(ctx, req, &env); err != nil { + return 0, fmt.Errorf("count executions: %w", err) + } + return env.WorkflowExecutions.Count, nil +} + // GetExecution fetches a single execution by its UUID. func (c *Client) GetExecution(parent context.Context, uuid string) (*Execution, error) { ctx, cancel := c.CreateServiceContextWithTimeout(parent) diff --git a/internal/client/workflowdataclient/workflow_status.go b/internal/client/workflowdataclient/workflow_status.go index 25fe8d83..58dee226 100644 --- a/internal/client/workflowdataclient/workflow_status.go +++ b/internal/client/workflowdataclient/workflow_status.go @@ -80,15 +80,15 @@ query GetLatestDeployment($input: WorkflowDeploymentsInput!) { // ---- envelopes ---- type gqlWorkflowSummary struct { - UUID string `json:"uuid"` - Name string `json:"name"` - WorkflowID string `json:"workflowId"` - OwnerAddress string `json:"ownerAddress"` - Status string `json:"status"` - WorkflowSource string `json:"workflowSource"` - RegisteredAt time.Time `json:"registeredAt"` - ExecutedAt *time.Time `json:"executedAt"` - ExecutionCount int `json:"executionCount"` + UUID string `json:"uuid"` + Name string `json:"name"` + WorkflowID string `json:"workflowId"` + OwnerAddress string `json:"ownerAddress"` + Status string `json:"status"` + WorkflowSource string `json:"workflowSource"` + RegisteredAt time.Time `json:"registeredAt"` + ExecutedAt *time.Time `json:"executedAt"` + ExecutionCount int `json:"executionCount"` ExecutionCountByStatus struct { Success int `json:"success"` Failure int `json:"failure"` @@ -102,14 +102,14 @@ type getWorkflowSummaryEnvelope struct { } type gqlDeploymentRecord struct { - UUID string `json:"uuid"` - WorkflowID string `json:"workflowID"` - Status string `json:"status"` - DeployedAt time.Time `json:"deployedAt"` - TxHash *string `json:"txHash"` - BinaryURL *string `json:"binaryURL"` - ConfigURL *string `json:"configURL"` - ErrorMessage *string `json:"errorMessage"` + UUID string `json:"uuid"` + WorkflowID string `json:"workflowID"` + Status string `json:"status"` + DeployedAt time.Time `json:"deployedAt"` + TxHash *string `json:"txHash"` + BinaryURL *string `json:"binaryURL"` + ConfigURL *string `json:"configURL"` + ErrorMessage *string `json:"errorMessage"` } type getLatestDeploymentEnvelope struct { diff --git a/internal/workflowrender/execution.go b/internal/workflowrender/execution.go index cf14e968..3f68c01d 100644 --- a/internal/workflowrender/execution.go +++ b/internal/workflowrender/execution.go @@ -70,13 +70,13 @@ func PrintExecutionsTable(rows []workflowdataclient.Execution) { for i, e := range rows { ui.Bold(fmt.Sprintf("%d. %s", i+1, e.UUID)) - ui.Dim(fmt.Sprintf(" Workflow: %s (%s)", e.WorkflowName, e.WorkflowUUID)) + ui.Dim(fmt.Sprintf(" Workflow: %s", e.WorkflowName)) ui.Dim(fmt.Sprintf(" Status: %s", e.Status)) ui.Dim(fmt.Sprintf(" Started: %s", e.StartedAt.UTC().Format("2006-01-02 15:04:05"))) if e.FinishedAt != nil { ui.Dim(fmt.Sprintf(" Finished: %s (%s)", e.FinishedAt.UTC().Format("2006-01-02 15:04:05"), formatDuration(e.FinishedAt.Sub(e.StartedAt)))) } - ui.Line() + ui.Line() } } diff --git a/internal/workflowrender/workflow_status.go b/internal/workflowrender/workflow_status.go index 6c8b5137..7bd5f36b 100644 --- a/internal/workflowrender/workflow_status.go +++ b/internal/workflowrender/workflow_status.go @@ -40,8 +40,6 @@ func PrintWorkflowStatusTable(v WorkflowStatusView) { } } - - ui.Line() ui.Bold("Deployment") if v.Deployment != nil { @@ -109,7 +107,7 @@ func PrintWorkflowStatusJSON(v WorkflowStatusView) error { "executionCount": s.ExecutionCount, "successCount": s.SuccessCount, "failureCount": s.FailureCount, - }, + }, } if v.Deployment != nil { From ca8d8a5aca198e0890a9bd0f1925956960778faa Mon Sep 17 00:00:00 2001 From: Emmanuel Jacquier Date: Mon, 1 Jun 2026 13:39:43 -0400 Subject: [PATCH 11/14] Fixed deployment info --- cmd/workflow/wfstatus/status.go | 23 +++++++++++++++---- .../workflowdataclient/workflow_status.go | 6 ++++- internal/workflowrender/workflow_status.go | 14 +++++++++++ 3 files changed, 37 insertions(+), 6 deletions(-) diff --git a/cmd/workflow/wfstatus/status.go b/cmd/workflow/wfstatus/status.go index 79cb6529..af7b6d7c 100644 --- a/cmd/workflow/wfstatus/status.go +++ b/cmd/workflow/wfstatus/status.go @@ -16,6 +16,7 @@ import ( "github.com/smartcontractkit/cre-cli/internal/client/workflowdataclient" "github.com/smartcontractkit/cre-cli/internal/credentials" "github.com/smartcontractkit/cre-cli/internal/runtime" + "github.com/smartcontractkit/cre-cli/internal/tenantctx" "github.com/smartcontractkit/cre-cli/internal/ui" "github.com/smartcontractkit/cre-cli/internal/workflowrender" ) @@ -25,6 +26,7 @@ const outputFormatJSON = "json" // Handler fetches and renders a comprehensive workflow status view. type Handler struct { credentials *credentials.Credentials + tenantCtx *tenantctx.EnvironmentContext wdc *workflowdataclient.Client } @@ -32,12 +34,12 @@ type Handler struct { func NewHandler(ctx *runtime.Context) *Handler { gql := graphqlclient.New(ctx.Credentials, ctx.EnvironmentSet, ctx.Logger) wdc := workflowdataclient.New(gql, ctx.Logger) - return &Handler{credentials: ctx.Credentials, wdc: wdc} + return &Handler{credentials: ctx.Credentials, tenantCtx: ctx.TenantContext, wdc: wdc} } // NewHandlerWithClient builds a Handler with a pre-built client (for testing). func NewHandlerWithClient(ctx *runtime.Context, wdc *workflowdataclient.Client) *Handler { - return &Handler{credentials: ctx.Credentials, wdc: wdc} + return &Handler{credentials: ctx.Credentials, tenantCtx: ctx.TenantContext, wdc: wdc} } // resolveUUID returns the platform UUID for a workflow name or on-chain WorkflowID. @@ -106,7 +108,8 @@ func (h *Handler) Execute(ctx context.Context, arg, outputFormat string) error { spinner := ui.NewSpinner() spinner.Start("Fetching workflow status...") - from := time.Now().UTC().AddDate(0, 0, -30) + now := time.Now().UTC() + from := now.AddDate(-1, 0, 0) // 1-year lookback — mirrors Explorer behaviour var ( summary *workflowdataclient.WorkflowSummary @@ -124,7 +127,7 @@ func (h *Handler) Execute(ctx context.Context, arg, outputFormat string) error { }() go func() { defer wg.Done() - deployment, deployErr = h.wdc.GetLatestDeployment(ctx, uuid) + deployment, deployErr = h.wdc.GetLatestDeployment(ctx, uuid, from, now) }() go func() { defer wg.Done() @@ -150,15 +153,18 @@ func (h *Handler) Execute(ctx context.Context, arg, outputFormat string) error { if execErr != nil { return execErr } - // deployErr, succErr, failErr are non-fatal — degrade gracefully. + // deployErr, succErr, failErr are non-fatal — show errors but continue rendering. if deployErr != nil { deployment = nil + ui.Warning(fmt.Sprintf("Could not fetch deployment record: %s", deployErr.Error())) } if succErr != nil { successCount = 0 + ui.Warning(fmt.Sprintf("Could not fetch success count: %s", succErr.Error())) } if failErr != nil { failureCount = 0 + ui.Warning(fmt.Sprintf("Could not fetch failure count: %s", failErr.Error())) } summary.SuccessCount = successCount summary.FailureCount = failureCount @@ -169,10 +175,17 @@ func (h *Handler) Execute(ctx context.Context, arg, outputFormat string) error { lastExec = &executions[0] } + var registries []*tenantctx.Registry + if h.tenantCtx != nil { + registries = h.tenantCtx.Registries + } + view := workflowrender.WorkflowStatusView{ Summary: summary, Deployment: deployment, + DeploymentErr: deployErr, LastExecution: lastExec, + Registries: registries, } if outputFormat == outputFormatJSON { diff --git a/internal/client/workflowdataclient/workflow_status.go b/internal/client/workflowdataclient/workflow_status.go index 58dee226..65c22afa 100644 --- a/internal/client/workflowdataclient/workflow_status.go +++ b/internal/client/workflowdataclient/workflow_status.go @@ -158,13 +158,17 @@ func (c *Client) GetWorkflowSummary(parent context.Context, uuid string, _ time. } // GetLatestDeployment fetches the most recent deployment record for a workflow. -func (c *Client) GetLatestDeployment(parent context.Context, workflowUUID string) (*WorkflowDeploymentRecord, error) { +// from/to mirror what the Explorer UI passes — the backend requires them even though +// the schema marks them optional. +func (c *Client) GetLatestDeployment(parent context.Context, workflowUUID string, from, to time.Time) (*WorkflowDeploymentRecord, error) { ctx, cancel := c.CreateServiceContextWithTimeout(parent) defer cancel() req := graphql.NewRequest(getLatestDeploymentQuery) req.Var("input", map[string]any{ "workflowUUID": workflowUUID, + "from": from.UTC().Format(time.RFC3339), + "to": to.UTC().Format(time.RFC3339), "orderBy": map[string]any{ "field": "DEPLOYED_AT", "order": "DESC", diff --git a/internal/workflowrender/workflow_status.go b/internal/workflowrender/workflow_status.go index 7bd5f36b..1ac3ccf1 100644 --- a/internal/workflowrender/workflow_status.go +++ b/internal/workflowrender/workflow_status.go @@ -7,6 +7,7 @@ import ( "time" "github.com/smartcontractkit/cre-cli/internal/client/workflowdataclient" + "github.com/smartcontractkit/cre-cli/internal/tenantctx" "github.com/smartcontractkit/cre-cli/internal/ui" ) @@ -14,16 +15,27 @@ import ( type WorkflowStatusView struct { Summary *workflowdataclient.WorkflowSummary Deployment *workflowdataclient.WorkflowDeploymentRecord + DeploymentErr error LastExecution *workflowdataclient.Execution + Registries []*tenantctx.Registry } // PrintWorkflowStatusTable renders a rich workflow status view to stdout. func PrintWorkflowStatusTable(v WorkflowStatusView) { s := v.Summary ui.Line() + matched := ResolveWorkflowRegistry(s.WorkflowSource, v.Registries) + regID := RegistryIDOrSource(s.WorkflowSource, matched) + ui.Bold(fmt.Sprintf("Workflow: %s", s.Name)) ui.Dim(fmt.Sprintf(" Workflow ID: %s", s.WorkflowID)) ui.Dim(fmt.Sprintf(" Status: %s%s", s.Status, deploymentStatusHint(s.Status))) + ui.Dim(fmt.Sprintf(" Registry: %s", regID)) + if matched != nil && RegistryEligibleForContractRows(matched) && matched.Address != nil { + ui.Dim(fmt.Sprintf(" Address: %s", strings.TrimSpace(*matched.Address))) + } else if _, addr, ok := ParseContractWorkflowSource(s.WorkflowSource); ok && strings.TrimSpace(addr) != "" { + ui.Dim(fmt.Sprintf(" Address: %s", strings.TrimSpace(addr))) + } ui.Dim(fmt.Sprintf(" Registered: %s", s.RegisteredAt.UTC().Format("2006-01-02 15:04:05"))) if s.ExecutedAt != nil { @@ -55,6 +67,8 @@ func PrintWorkflowStatusTable(v WorkflowStatusView) { if d.ErrorMessage != nil && *d.ErrorMessage != "" { ui.Dim(fmt.Sprintf(" Error: %s", *d.ErrorMessage)) } + } else if v.DeploymentErr != nil { + ui.Dim(" Unavailable — see warning above") } else { ui.Dim(" No deployment record found") } From 0cd5f0f582c14dc5fa133b45d04d28bb1f07d4e1 Mon Sep 17 00:00:00 2001 From: Emmanuel Jacquier Date: Tue, 2 Jun 2026 08:58:30 -0400 Subject: [PATCH 12/14] fix linter issue --- cmd/workflow/workflow.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/workflow/workflow.go b/cmd/workflow/workflow.go index 7248a904..bc6dbc98 100644 --- a/cmd/workflow/workflow.go +++ b/cmd/workflow/workflow.go @@ -10,7 +10,6 @@ import ( "github.com/smartcontractkit/cre-cli/cmd/workflow/deploy" "github.com/smartcontractkit/cre-cli/cmd/workflow/execution" workflowget "github.com/smartcontractkit/cre-cli/cmd/workflow/get" - "github.com/smartcontractkit/cre-cli/cmd/workflow/wfstatus" "github.com/smartcontractkit/cre-cli/cmd/workflow/hash" "github.com/smartcontractkit/cre-cli/cmd/workflow/limits" workflowlist "github.com/smartcontractkit/cre-cli/cmd/workflow/list" @@ -18,6 +17,7 @@ import ( "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate" supported_chains "github.com/smartcontractkit/cre-cli/cmd/workflow/supported_chains" "github.com/smartcontractkit/cre-cli/cmd/workflow/test" + "github.com/smartcontractkit/cre-cli/cmd/workflow/wfstatus" "github.com/smartcontractkit/cre-cli/internal/runtime" ) From 8650f2f40499ec9f80f24c8a4c9767e3dd4d2d65 Mon Sep 17 00:00:00 2001 From: Emmanuel Jacquier Date: Tue, 2 Jun 2026 15:06:45 -0400 Subject: [PATCH 13/14] Fix uuid vs executionId --- cmd/workflow/execution/events.go | 7 +- cmd/workflow/execution/execution_test.go | 10 +-- cmd/workflow/execution/logs.go | 7 +- cmd/workflow/execution/resolve.go | 71 +++++++++++++++++++ cmd/workflow/execution/status.go | 38 ++++++++-- .../client/workflowdataclient/execution.go | 33 +++++++++ internal/workflowrender/execution.go | 61 +++++++++++++--- internal/workflowrender/workflow_status.go | 10 +-- 8 files changed, 210 insertions(+), 27 deletions(-) create mode 100644 cmd/workflow/execution/resolve.go diff --git a/cmd/workflow/execution/events.go b/cmd/workflow/execution/events.go index 8c53281b..a16d0473 100644 --- a/cmd/workflow/execution/events.go +++ b/cmd/workflow/execution/events.go @@ -61,10 +61,15 @@ func (h *EventsHandler) Execute(ctx context.Context, in EventsInputs) error { return fmt.Errorf("credentials not available — run `cre login` and retry") } + uuid, err := resolveExecutionUUID(ctx, h.wdc, in.ExecutionUUID) + if err != nil { + return err + } + spinner := ui.NewSpinner() spinner.Start("Fetching execution events...") events, err := h.wdc.ListExecutionEvents(ctx, workflowdataclient.ListEventsInput{ - ExecutionUUID: in.ExecutionUUID, + ExecutionUUID: uuid, CapabilityID: in.CapabilityID, Status: in.Status, }) diff --git a/cmd/workflow/execution/execution_test.go b/cmd/workflow/execution/execution_test.go index 5333683f..aca86f63 100644 --- a/cmd/workflow/execution/execution_test.go +++ b/cmd/workflow/execution/execution_test.go @@ -262,7 +262,7 @@ func TestStatus_NotFound(t *testing.T) { rtCtx := rtCtxFor(t, srv.URL) h := execution.NewStatusHandlerWithClient(rtCtx, wdcFor(t, srv.URL)) - err := h.Execute(context.Background(), execution.StatusInputs{ExecutionUUID: "missing-uuid"}) + err := h.Execute(context.Background(), execution.StatusInputs{ExecutionUUID: "00000000-0000-0000-0000-000000000001"}) require.Error(t, err) assert.Contains(t, err.Error(), "not found") } @@ -295,7 +295,7 @@ func TestStatus_FailureShowsErrors(t *testing.T) { h := execution.NewStatusHandlerWithClient(rtCtx, wdcFor(t, srv.URL)) out := captureStdout(t, func() { - err := h.Execute(context.Background(), execution.StatusInputs{ExecutionUUID: "exec-uuid-1", OutputFormat: "json"}) + err := h.Execute(context.Background(), execution.StatusInputs{ExecutionUUID: "05ace5cf-85ae-448b-9f42-270d42974d35", OutputFormat: "json"}) require.NoError(t, err) }) @@ -345,7 +345,7 @@ func TestEvents_JSON(t *testing.T) { h := execution.NewEventsHandlerWithClient(rtCtx, wdcFor(t, srv.URL)) out := captureStdout(t, func() { - err := h.Execute(context.Background(), execution.EventsInputs{ExecutionUUID: "exec-1", OutputFormat: "json"}) + err := h.Execute(context.Background(), execution.EventsInputs{ExecutionUUID: "05ace5cf-85ae-448b-9f42-270d42974d35", OutputFormat: "json"}) require.NoError(t, err) }) @@ -390,7 +390,7 @@ func TestLogs_NodeFilter_ClientSide(t *testing.T) { out := captureStdout(t, func() { err := h.Execute(context.Background(), execution.LogsInputs{ - ExecutionUUID: "exec-1", + ExecutionUUID: "05ace5cf-85ae-448b-9f42-270d42974d35", NodeFilter: "ProcessData", OutputFormat: "json", }) @@ -426,7 +426,7 @@ func TestLogs_NoFilter_ReturnsAll(t *testing.T) { h := execution.NewLogsHandlerWithClient(rtCtx, wdcFor(t, srv.URL)) out := captureStdout(t, func() { - err := h.Execute(context.Background(), execution.LogsInputs{ExecutionUUID: "exec-1", OutputFormat: "json"}) + err := h.Execute(context.Background(), execution.LogsInputs{ExecutionUUID: "05ace5cf-85ae-448b-9f42-270d42974d35", OutputFormat: "json"}) require.NoError(t, err) }) diff --git a/cmd/workflow/execution/logs.go b/cmd/workflow/execution/logs.go index 1fc60e3d..3129e339 100644 --- a/cmd/workflow/execution/logs.go +++ b/cmd/workflow/execution/logs.go @@ -55,9 +55,14 @@ func (h *LogsHandler) Execute(ctx context.Context, in LogsInputs) error { return fmt.Errorf("credentials not available — run `cre login` and retry") } + uuid, err := resolveExecutionUUID(ctx, h.wdc, in.ExecutionUUID) + if err != nil { + return err + } + spinner := ui.NewSpinner() spinner.Start("Fetching execution logs...") - logs, err := h.wdc.ListExecutionLogs(ctx, in.ExecutionUUID) + logs, err := h.wdc.ListExecutionLogs(ctx, uuid) spinner.Stop() if err != nil { return err diff --git a/cmd/workflow/execution/resolve.go b/cmd/workflow/execution/resolve.go new file mode 100644 index 00000000..1cdb92ce --- /dev/null +++ b/cmd/workflow/execution/resolve.go @@ -0,0 +1,71 @@ +package execution + +import ( + "context" + "fmt" + + "github.com/smartcontractkit/cre-cli/internal/client/workflowdataclient" + "github.com/smartcontractkit/cre-cli/internal/ui" +) + +// resolveExecutionUUID accepts either a platform UUID (8-4-4-4-12) or an +// on-chain hex execution ID (64-char hex shown in the Explorer UI) and returns +// the platform UUID needed for API calls. +func resolveExecutionUUID(ctx context.Context, wdc *workflowdataclient.Client, arg string) (string, error) { + if looksLikeUUID(arg) { + return arg, nil + } + if looksLikeOnChainExecutionID(arg) { + spinner := ui.NewSpinner() + spinner.Start(fmt.Sprintf("Resolving execution ID %q...", arg)) + exec, err := wdc.FindExecutionByOnChainID(ctx, arg) + spinner.Stop() + if err != nil { + return "", err + } + return exec.UUID, nil + } + return "", fmt.Errorf("%q is not a valid execution UUID or on-chain execution ID", arg) +} + +// looksLikeUUID returns true for the standard UUID shape (8-4-4-4-12). +func looksLikeUUID(s string) bool { + parts := splitDash(s) + if len(parts) != 5 { + return false + } + lengths := []int{8, 4, 4, 4, 12} + for i, p := range parts { + if len(p) != lengths[i] { + return false + } + } + return true +} + +// looksLikeOnChainExecutionID returns true for 64-char lowercase hex strings +// matching the execution id format shown in the Explorer UI. +func looksLikeOnChainExecutionID(s string) bool { + if len(s) != 64 { + return false + } + for _, c := range s { + if (c < '0' || c > '9') && (c < 'a' || c > 'f') && (c < 'A' || c > 'F') { + return false + } + } + return true +} + +func splitDash(s string) []string { + var parts []string + start := 0 + for i := 0; i < len(s); i++ { + if s[i] == '-' { + parts = append(parts, s[start:i]) + start = i + 1 + } + } + parts = append(parts, s[start:]) + return parts +} diff --git a/cmd/workflow/execution/status.go b/cmd/workflow/execution/status.go index 68893e29..abd17406 100644 --- a/cmd/workflow/execution/status.go +++ b/cmd/workflow/execution/status.go @@ -3,6 +3,7 @@ package execution import ( "context" "fmt" + "sync" "github.com/spf13/cobra" @@ -51,16 +52,45 @@ func (h *StatusHandler) Execute(ctx context.Context, in StatusInputs) error { spinner := ui.NewSpinner() spinner.Start("Fetching execution...") - exec, err := h.wdc.GetExecution(ctx, in.ExecutionUUID) - spinner.Stop() + + var ( + exec *workflowdataclient.Execution + failEvents []workflowdataclient.ExecutionEvent + execErr error + wg sync.WaitGroup + ) + + uuid, err := resolveExecutionUUID(ctx, h.wdc, in.ExecutionUUID) if err != nil { + spinner.Stop() return err } + wg.Add(1) + go func() { + defer wg.Done() + exec, execErr = h.wdc.GetExecution(ctx, uuid) + }() + wg.Wait() + + // If the execution failed, fetch failed events in parallel with rendering setup. + if execErr == nil && exec.Status == workflowdataclient.ExecutionStatusFailure { + failStatus := "failure" + failEvents, _ = h.wdc.ListExecutionEvents(ctx, workflowdataclient.ListEventsInput{ + ExecutionUUID: uuid, + Status: &failStatus, + }) + } + + spinner.Stop() + if execErr != nil { + return execErr + } + if in.OutputFormat == outputFormatJSON { - return workflowrender.PrintExecutionDetailJSON(*exec) + return workflowrender.PrintExecutionDetailJSON(*exec, failEvents) } - workflowrender.PrintExecutionDetailTable(*exec) + workflowrender.PrintExecutionDetailTable(*exec, failEvents) return nil } diff --git a/internal/client/workflowdataclient/execution.go b/internal/client/workflowdataclient/execution.go index 62a7185b..dec41981 100644 --- a/internal/client/workflowdataclient/execution.go +++ b/internal/client/workflowdataclient/execution.go @@ -3,6 +3,7 @@ package workflowdataclient import ( "context" "fmt" + "strings" "time" "github.com/machinebox/graphql" @@ -37,7 +38,9 @@ type ExecutionError struct { // Execution is a single workflow execution record. type Execution struct { UUID string + ID string // on-chain execution ID shown in the Explorer UI WorkflowUUID string + WorkflowID string // on-chain workflow hash (workflowId scalar) WorkflowName string Status ExecutionStatus StartedAt time.Time @@ -93,7 +96,9 @@ query ListExecutions($input: WorkflowExecutionsInput!) { workflowExecutions(input: $input) { data { uuid + id workflowUUID + workflowId workflowName status startedAt @@ -114,7 +119,9 @@ query GetExecution($input: WorkflowExecutionInput!) { workflowExecution(input: $input) { data { uuid + id workflowUUID + workflowId workflowName status startedAt @@ -168,7 +175,9 @@ type gqlExecutionError struct { type gqlExecution struct { UUID string `json:"uuid"` + ID string `json:"id"` WorkflowUUID string `json:"workflowUUID"` + WorkflowID string `json:"workflowId"` WorkflowName string `json:"workflowName"` Status string `json:"status"` StartedAt time.Time `json:"startedAt"` @@ -272,6 +281,28 @@ func (c *Client) ListExecutions(parent context.Context, in ListExecutionsInput) return toExecutions(env.WorkflowExecutions.Data), nil } +// FindExecutionByOnChainID resolves the platform UUID for an execution given its +// on-chain hex ID (the identifier shown in the Explorer UI). It searches recent +// executions and matches on the id field. +func (c *Client) FindExecutionByOnChainID(parent context.Context, onChainID string) (*Execution, error) { + // The API has no direct filter by on-chain ID, so we fetch a broad page + // and match client-side. The on-chain ID appears in recent executions. + executions, err := c.ListExecutions(parent, ListExecutionsInput{Limit: 100}) + if err != nil { + return nil, fmt.Errorf("searching for execution %q: %w", onChainID, err) + } + for _, e := range executions { + if strings.EqualFold(e.ID, onChainID) { + full, err := c.GetExecution(parent, e.UUID) + if err != nil { + return nil, err + } + return full, nil + } + } + return nil, fmt.Errorf("execution with ID %q not found", onChainID) +} + // CountExecutions returns the total number of executions matching the given filters. // It fetches only a single-item page — only the count field is used. func (c *Client) CountExecutions(parent context.Context, workflowUUID string, statuses []ExecutionStatus) (int, error) { @@ -391,7 +422,9 @@ func toExecution(g gqlExecution) Execution { } return Execution{ UUID: g.UUID, + ID: g.ID, WorkflowUUID: g.WorkflowUUID, + WorkflowID: g.WorkflowID, WorkflowName: g.WorkflowName, Status: ExecutionStatus(g.Status), StartedAt: g.StartedAt, diff --git a/internal/workflowrender/execution.go b/internal/workflowrender/execution.go index 3f68c01d..68880f58 100644 --- a/internal/workflowrender/execution.go +++ b/internal/workflowrender/execution.go @@ -69,7 +69,7 @@ func PrintExecutionsTable(rows []workflowdataclient.Execution) { ui.Line() for i, e := range rows { - ui.Bold(fmt.Sprintf("%d. %s", i+1, e.UUID)) + ui.Bold(fmt.Sprintf("%d. %s", i+1, e.ID)) ui.Dim(fmt.Sprintf(" Workflow: %s", e.WorkflowName)) ui.Dim(fmt.Sprintf(" Status: %s", e.Status)) ui.Dim(fmt.Sprintf(" Started: %s", e.StartedAt.UTC().Format("2006-01-02 15:04:05"))) @@ -92,15 +92,35 @@ type executionErrorJSON struct { Count int `json:"count"` } -// PrintExecutionDetailJSON marshals a single execution with its errors to stdout. -func PrintExecutionDetailJSON(e workflowdataclient.Execution) error { +// PrintExecutionDetailJSON marshals a single execution with its errors and failed events to stdout. +func PrintExecutionDetailJSON(e workflowdataclient.Execution, failedEvents []workflowdataclient.ExecutionEvent) error { errs := make([]executionErrorJSON, 0, len(e.Errors)) for _, err := range e.Errors { errs = append(errs, executionErrorJSON{Error: err.Error, Count: err.Count}) } - detail := executionDetailJSON{ - executionJSON: toExecutionJSON(e), - Errors: errs, + type failedEventJSON struct { + CapabilityID string `json:"capabilityID"` + Method *string `json:"method,omitempty"` + Errors []capabilityErrorJSON `json:"errors,omitempty"` + } + fevs := make([]failedEventJSON, 0, len(failedEvents)) + for _, ev := range failedEvents { + fe := failedEventJSON{CapabilityID: ev.CapabilityID, Method: ev.Method} + for _, ce := range ev.Errors { + fe.Errors = append(fe.Errors, capabilityErrorJSON{Error: ce.Error, Count: ce.Count}) + } + fevs = append(fevs, fe) + } + type detailJSON struct { + executionDetailJSON + FailedEvents []failedEventJSON `json:"failedEvents,omitempty"` + } + detail := detailJSON{ + executionDetailJSON: executionDetailJSON{ + executionJSON: toExecutionJSON(e), + Errors: errs, + }, + FailedEvents: fevs, } data, err := json.MarshalIndent(detail, "", " ") if err != nil { @@ -110,12 +130,12 @@ func PrintExecutionDetailJSON(e workflowdataclient.Execution) error { return nil } -// PrintExecutionDetailTable renders a single execution with its errors as a bulleted detail view. -func PrintExecutionDetailTable(e workflowdataclient.Execution) { +// PrintExecutionDetailTable renders a single execution with failed capability events inline. +func PrintExecutionDetailTable(e workflowdataclient.Execution, failedEvents []workflowdataclient.ExecutionEvent) { ui.Line() - ui.Bold(fmt.Sprintf("Execution: %s", e.UUID)) - ui.Dim(fmt.Sprintf(" Workflow: %s", e.WorkflowName)) - ui.Dim(fmt.Sprintf(" UUID: %s", e.WorkflowUUID)) + ui.Bold(fmt.Sprintf("Execution: %s", e.ID)) + ui.Dim(fmt.Sprintf(" Workflow: %s", e.WorkflowName)) + ui.Dim(fmt.Sprintf(" Workflow ID: %s", e.WorkflowID)) ui.Dim(fmt.Sprintf(" Status: %s", e.Status)) timeStr := e.StartedAt.UTC().Format("2006-01-02 15:04:05") @@ -136,6 +156,25 @@ func PrintExecutionDetailTable(e workflowdataclient.Execution) { } } + if len(failedEvents) > 0 { + ui.Line() + ui.Bold("Failures:") + for _, ev := range failedEvents { + method := "" + if ev.Method != nil && *ev.Method != "" { + method = " " + *ev.Method + } + ui.Dim(fmt.Sprintf(" %s%s", ev.CapabilityID, method)) + for _, ce := range ev.Errors { + ui.Dim(fmt.Sprintf(" - %s (x%d)", ce.Error, ce.Count)) + } + } + } + + ui.Line() + ui.Bold("Debug further:") + ui.Dim(fmt.Sprintf(" cre workflow execution events %s", e.ID)) + ui.Dim(fmt.Sprintf(" cre workflow execution logs %s", e.ID)) ui.Line() } diff --git a/internal/workflowrender/workflow_status.go b/internal/workflowrender/workflow_status.go index 1ac3ccf1..e976ad32 100644 --- a/internal/workflowrender/workflow_status.go +++ b/internal/workflowrender/workflow_status.go @@ -83,7 +83,7 @@ func PrintWorkflowStatusTable(v WorkflowStatusView) { e := v.LastExecution ui.Line() ui.Bold("Last execution") - ui.Dim(fmt.Sprintf(" ID: %s", e.UUID)) + ui.Dim(fmt.Sprintf(" ID: %s", e.ID)) ui.Dim(fmt.Sprintf(" Status: %s", e.Status)) ui.Dim(fmt.Sprintf(" Started: %s", e.StartedAt.UTC().Format("2006-01-02 15:04:05"))) if e.FinishedAt != nil { @@ -96,10 +96,10 @@ func PrintWorkflowStatusTable(v WorkflowStatusView) { } } ui.Line() - ui.Bold("Next steps:") - ui.Dim(fmt.Sprintf(" cre workflow execution list %s", s.Name)) - ui.Dim(fmt.Sprintf(" cre workflow execution events %s", e.UUID)) - ui.Dim(fmt.Sprintf(" cre workflow execution logs %s", e.UUID)) + ui.Bold("Debug further:") + ui.Dim(fmt.Sprintf(" cre workflow execution status %s", e.ID)) + ui.Dim(fmt.Sprintf(" cre workflow execution events %s", e.ID)) + ui.Dim(fmt.Sprintf(" cre workflow execution logs %s", e.ID)) } else if s.Status == "PENDING" { ui.Line() ui.Dim(" Workflow has not executed yet — it may still be activating in the DON.") From 9dbfdabc71f8301646252df9878ae505c10be124 Mon Sep 17 00:00:00 2001 From: Emmanuel Jacquier Date: Tue, 2 Jun 2026 15:25:12 -0400 Subject: [PATCH 14/14] UTC display --- internal/workflowrender/execution.go | 14 +++++++------- internal/workflowrender/workflow_status.go | 8 ++++---- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/internal/workflowrender/execution.go b/internal/workflowrender/execution.go index 68880f58..b9bf6320 100644 --- a/internal/workflowrender/execution.go +++ b/internal/workflowrender/execution.go @@ -72,9 +72,9 @@ func PrintExecutionsTable(rows []workflowdataclient.Execution) { ui.Bold(fmt.Sprintf("%d. %s", i+1, e.ID)) ui.Dim(fmt.Sprintf(" Workflow: %s", e.WorkflowName)) ui.Dim(fmt.Sprintf(" Status: %s", e.Status)) - ui.Dim(fmt.Sprintf(" Started: %s", e.StartedAt.UTC().Format("2006-01-02 15:04:05"))) + ui.Dim(fmt.Sprintf(" Started: %s", e.StartedAt.UTC().Format("2006-01-02 15:04:05 UTC"))) if e.FinishedAt != nil { - ui.Dim(fmt.Sprintf(" Finished: %s (%s)", e.FinishedAt.UTC().Format("2006-01-02 15:04:05"), formatDuration(e.FinishedAt.Sub(e.StartedAt)))) + ui.Dim(fmt.Sprintf(" Finished: %s (%s)", e.FinishedAt.UTC().Format("2006-01-02 15:04:05 UTC"), formatDuration(e.FinishedAt.Sub(e.StartedAt)))) } ui.Line() } @@ -138,11 +138,11 @@ func PrintExecutionDetailTable(e workflowdataclient.Execution, failedEvents []wo ui.Dim(fmt.Sprintf(" Workflow ID: %s", e.WorkflowID)) ui.Dim(fmt.Sprintf(" Status: %s", e.Status)) - timeStr := e.StartedAt.UTC().Format("2006-01-02 15:04:05") + timeStr := e.StartedAt.UTC().Format("2006-01-02 15:04:05 UTC") if e.FinishedAt != nil { timeStr = fmt.Sprintf("%s to %s (%s)", - e.StartedAt.UTC().Format("2006-01-02 15:04:05"), - e.FinishedAt.UTC().Format("15:04:05"), + e.StartedAt.UTC().Format("2006-01-02 15:04:05 UTC"), + e.FinishedAt.UTC().Format("15:04:05 UTC"), formatDuration(e.FinishedAt.Sub(e.StartedAt)), ) } @@ -249,7 +249,7 @@ func PrintEventsTable(events []workflowdataclient.ExecutionEvent) { ui.Bold(fmt.Sprintf("%d. %s", i+1, ev.CapabilityID)) ui.Dim(fmt.Sprintf(" Method: %s", method)) ui.Dim(fmt.Sprintf(" Status: %s", ev.Status)) - ui.Dim(fmt.Sprintf(" Started: %s", ev.StartedAt.UTC().Format("2006-01-02 15:04:05"))) + ui.Dim(fmt.Sprintf(" Started: %s", ev.StartedAt.UTC().Format("2006-01-02 15:04:05 UTC"))) ui.Dim(fmt.Sprintf(" Duration: %s", dur)) if len(ev.Errors) > 0 { errMsgs := make([]string, 0, len(ev.Errors)) @@ -302,7 +302,7 @@ func PrintLogsTable(logs []workflowdataclient.ExecutionLog, nodeFilter string) { continue } ui.Print(fmt.Sprintf("[%s] [%s] %s", - l.Timestamp.UTC().Format("2006-01-02 15:04:05"), + l.Timestamp.UTC().Format("2006-01-02 15:04:05 UTC"), l.NodeID, l.Message, )) diff --git a/internal/workflowrender/workflow_status.go b/internal/workflowrender/workflow_status.go index e976ad32..bd842fff 100644 --- a/internal/workflowrender/workflow_status.go +++ b/internal/workflowrender/workflow_status.go @@ -36,10 +36,10 @@ func PrintWorkflowStatusTable(v WorkflowStatusView) { } else if _, addr, ok := ParseContractWorkflowSource(s.WorkflowSource); ok && strings.TrimSpace(addr) != "" { ui.Dim(fmt.Sprintf(" Address: %s", strings.TrimSpace(addr))) } - ui.Dim(fmt.Sprintf(" Registered: %s", s.RegisteredAt.UTC().Format("2006-01-02 15:04:05"))) + ui.Dim(fmt.Sprintf(" Registered: %s", s.RegisteredAt.UTC().Format("2006-01-02 15:04:05 UTC"))) if s.ExecutedAt != nil { - ui.Dim(fmt.Sprintf(" Last executed: %s", s.ExecutedAt.UTC().Format("2006-01-02 15:04:05"))) + ui.Dim(fmt.Sprintf(" Last executed: %s", s.ExecutedAt.UTC().Format("2006-01-02 15:04:05 UTC"))) if s.Status == "PENDING" { gap := s.ExecutedAt.Sub(s.RegisteredAt) ui.Dim(fmt.Sprintf(" Activation gap: %s", formatDuration(gap))) @@ -57,7 +57,7 @@ func PrintWorkflowStatusTable(v WorkflowStatusView) { if v.Deployment != nil { d := v.Deployment ui.Dim(fmt.Sprintf(" Status: %s", d.Status)) - ui.Dim(fmt.Sprintf(" Deployed at: %s", d.DeployedAt.UTC().Format("2006-01-02 15:04:05"))) + ui.Dim(fmt.Sprintf(" Deployed at: %s", d.DeployedAt.UTC().Format("2006-01-02 15:04:05 UTC"))) if d.TxHash != nil && *d.TxHash != "" { ui.Dim(fmt.Sprintf(" Tx hash: %s", *d.TxHash)) } @@ -85,7 +85,7 @@ func PrintWorkflowStatusTable(v WorkflowStatusView) { ui.Bold("Last execution") ui.Dim(fmt.Sprintf(" ID: %s", e.ID)) ui.Dim(fmt.Sprintf(" Status: %s", e.Status)) - ui.Dim(fmt.Sprintf(" Started: %s", e.StartedAt.UTC().Format("2006-01-02 15:04:05"))) + ui.Dim(fmt.Sprintf(" Started: %s", e.StartedAt.UTC().Format("2006-01-02 15:04:05 UTC"))) if e.FinishedAt != nil { ui.Dim(fmt.Sprintf(" Duration: %s", formatDuration(e.FinishedAt.Sub(e.StartedAt)))) }