diff --git a/cmd/root.go b/cmd/root.go index d0bcbccc..7f64c0b1 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -555,6 +555,12 @@ func isLoadSettings(cmd *cobra.Command) bool { "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 workflow status": {}, "cre account": {}, "cre secrets": {}, "cre templates": {}, diff --git a/cmd/workflow/deploy/register.go b/cmd/workflow/deploy/register.go index 1d884bde..6d9a1a56 100644 --- a/cmd/workflow/deploy/register.go +++ b/cmd/workflow/deploy/register.go @@ -79,6 +79,14 @@ func (h *handler) handleUpsert(ctx context.Context, params client.RegisterWorkfl 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 1369fd8a..e5b0d429 100644 --- a/cmd/workflow/deploy/registry_deploy_strategy_private.go +++ b/cmd/workflow/deploy/registry_deploy_strategy_private.go @@ -77,6 +77,14 @@ func (a *privateRegistryDeployStrategy) Upsert(ctx context.Context) 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/events.go b/cmd/workflow/execution/events.go new file mode 100644 index 00000000..a16d0473 --- /dev/null +++ b/cmd/workflow/execution/events.go @@ -0,0 +1,118 @@ +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, 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) + } + 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") + } + + 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: uuid, + 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 + var jsonFlag bool + + 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, jsonFlag) + 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`) + cmd.Flags().BoolVar(&jsonFlag, "json", false, "Output as JSON (shorthand for --output=json)") + 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..aca86f63 --- /dev/null +++ b/cmd/workflow/execution/execution_test.go @@ -0,0 +1,436 @@ +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{ + "uuid": "wf-uuid-active", + "name": "my-workflow", + "workflowId": "abc123onchain", + "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: "00000000-0000-0000-0000-000000000001"}) + 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: "05ace5cf-85ae-448b-9f42-270d42974d35", 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: "05ace5cf-85ae-448b-9f42-270d42974d35", 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: "05ace5cf-85ae-448b-9f42-270d42974d35", + 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: "05ace5cf-85ae-448b-9f42-270d42974d35", 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..8143f57d --- /dev/null +++ b/cmd/workflow/execution/list.go @@ -0,0 +1,306 @@ +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, + 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) + } + + 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 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 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) +} + +// 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...", 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) + } + + // 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), name) { + matches = append(matches, r) + } + } + + if len(matches) == 0 { + return "", fmt.Errorf("no workflow found with name %q", name) + } + + // 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].UUID, nil + } + if len(active) > 1 { + 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)", name, matches[0].Status)) + } + if matches[0].UUID == "" { + return "", fmt.Errorf("workflow %q resolved but has no platform UUID", name) + } + return matches[0].UUID, 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 + var jsonFlag bool + + cmd := &cobra.Command{ + 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 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 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", + 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, jsonFlag) + 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`) + cmd.Flags().BoolVar(&jsonFlag, "json", false, "Output as JSON (shorthand for --output=json)") + + return cmd +} + +// 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 + } + for _, c := range s { + if (c < '0' || c > '9') && (c < 'a' || c > 'f') && (c < 'A' || c > 'F') { + 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..3129e339 --- /dev/null +++ b/cmd/workflow/execution/logs.go @@ -0,0 +1,105 @@ +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, 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) + } + 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") + } + + 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, uuid) + 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 + var jsonFlag bool + + 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, jsonFlag) + 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`) + cmd.Flags().BoolVar(&jsonFlag, "json", false, "Output as JSON (shorthand for --output=json)") + return cmd +} 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 new file mode 100644 index 00000000..abd17406 --- /dev/null +++ b/cmd/workflow/execution/status.go @@ -0,0 +1,121 @@ +package execution + +import ( + "context" + "fmt" + "sync" + + "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, 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) + } + 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...") + + 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, failEvents) + } + workflowrender.PrintExecutionDetailTable(*exec, failEvents) + return nil +} + +func newStatus(runtimeContext *runtime.Context) *cobra.Command { + var outputFormat string + var jsonFlag bool + + 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, jsonFlag) + if err != nil { + return err + } + return NewStatusHandler(runtimeContext).Execute(cmd.Context(), in) + }, + } + + 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 +} diff --git a/cmd/workflow/list/list.go b/cmd/workflow/list/list.go index eed93d49..473edbb3 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 } @@ -120,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", @@ -132,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 @@ -143,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 } diff --git a/cmd/workflow/wfstatus/status.go b/cmd/workflow/wfstatus/status.go new file mode 100644 index 00000000..af7b6d7c --- /dev/null +++ b/cmd/workflow/wfstatus/status.go @@ -0,0 +1,242 @@ +// 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/tenantctx" + "github.com/smartcontractkit/cre-cli/internal/ui" + "github.com/smartcontractkit/cre-cli/internal/workflowrender" +) + +const outputFormatJSON = "json" + +// Handler fetches and renders a comprehensive workflow status view. +type Handler struct { + credentials *credentials.Credentials + tenantCtx *tenantctx.EnvironmentContext + 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, 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, tenantCtx: ctx.TenantContext, 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...") + + now := time.Now().UTC() + from := now.AddDate(-1, 0, 0) // 1-year lookback — mirrors Explorer behaviour + + var ( + summary *workflowdataclient.WorkflowSummary + deployment *workflowdataclient.WorkflowDeploymentRecord + executions []workflowdataclient.Execution + successCount, failureCount int + summaryErr, deployErr, execErr, succErr, failErr error + wg sync.WaitGroup + ) + + wg.Add(5) + 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, from, now) + }() + go func() { + defer wg.Done() + executions, execErr = h.wdc.ListExecutions(ctx, workflowdataclient.ListExecutionsInput{ + WorkflowUUID: &uuid, + 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() + + if summaryErr != nil { + return summaryErr + } + if execErr != nil { + return execErr + } + // 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 + summary.ExecutionCount = successCount + failureCount + + var lastExec *workflowdataclient.Execution + if len(executions) > 0 { + 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 { + 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 c301b83a..bc6dbc98 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" @@ -16,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" ) @@ -28,6 +30,8 @@ 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/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 + diff --git a/internal/client/workflowdataclient/execution.go b/internal/client/workflowdataclient/execution.go new file mode 100644 index 00000000..dec41981 --- /dev/null +++ b/internal/client/workflowdataclient/execution.go @@ -0,0 +1,443 @@ +package workflowdataclient + +import ( + "context" + "fmt" + "strings" + "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 + 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 + 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 + id + workflowUUID + workflowId + workflowName + status + startedAt + finishedAt + creditUsed + errors { + error + count + } + } + count + } +} +` + +const getExecutionQuery = ` +query GetExecution($input: WorkflowExecutionInput!) { + workflowExecution(input: $input) { + data { + uuid + id + workflowUUID + workflowId + 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"` + 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"` + 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 +} + +// 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) { + 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) + 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(e)) + } + 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(g)) + } + 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(e)) + } + return Execution{ + UUID: g.UUID, + ID: g.ID, + WorkflowUUID: g.WorkflowUUID, + WorkflowID: g.WorkflowID, + 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/workflow_status.go b/internal/client/workflowdataclient/workflow_status.go new file mode 100644 index 00000000..65c22afa --- /dev/null +++ b/internal/client/workflowdataclient/workflow_status.go @@ -0,0 +1,202 @@ +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. +// 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", + }, + "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/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"` diff --git a/internal/workflowrender/execution.go b/internal/workflowrender/execution.go new file mode 100644 index 00000000..b9bf6320 --- /dev/null +++ b/internal/workflowrender/execution.go @@ -0,0 +1,330 @@ +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.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 UTC"))) + if e.FinishedAt != nil { + 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() + } +} + +// ---- 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 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}) + } + 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 { + return err + } + fmt.Println(string(data)) + return nil +} + +// 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.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 UTC") + if e.FinishedAt != nil { + timeStr = fmt.Sprintf("%s to %s (%s)", + 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)), + ) + } + ui.Dim(fmt.Sprintf(" Time: %s", timeStr)) + + 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)) + } + } + + 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() +} + +// ---- 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 UTC"))) + 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 UTC"), + 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()) +} diff --git a/internal/workflowrender/workflow_status.go b/internal/workflowrender/workflow_status.go new file mode 100644 index 00000000..bd842fff --- /dev/null +++ b/internal/workflowrender/workflow_status.go @@ -0,0 +1,183 @@ +package workflowrender + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/smartcontractkit/cre-cli/internal/client/workflowdataclient" + "github.com/smartcontractkit/cre-cli/internal/tenantctx" + "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 + 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 UTC"))) + + if s.ExecutedAt != nil { + 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))) + } + } 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 UTC"))) + 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 if v.DeploymentErr != nil { + ui.Dim(" Unavailable — see warning above") + } 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.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 UTC"))) + 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("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.") + } + + 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) +}