Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 49 additions & 5 deletions cmd/run/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"io"
"net/http"
"strconv"
"time"

khhttp "github.com/keeperhub/cli/internal/http"
Expand All @@ -15,10 +16,11 @@ import (

// RunStatusResponse is the response from GET /api/workflows/executions/{id}/status.
type RunStatusResponse struct {
Status string `json:"status"`
NodeStatuses []NodeStatus `json:"nodeStatuses"`
Progress RunProgress `json:"progress"`
ErrorContext any `json:"errorContext"`
Status string `json:"status"`
NodeStatuses []NodeStatus `json:"nodeStatuses"`
Progress RunProgress `json:"progress"`
ErrorContext any `json:"errorContext"`
TransactionHashes []TransactionHashEntry `json:"transactionHashes"`
}

// NodeStatus holds per-node execution status.
Expand All @@ -37,6 +39,17 @@ type RunProgress struct {
Percentage int `json:"percentage"`
}

// TransactionHashEntry is an on-chain transaction broadcast by a workflow step.
// IterationIndex is set only for hashes produced inside a For-Each iteration.
type TransactionHashEntry struct {
Hash string `json:"hash"`
NodeID string `json:"nodeId"`
NodeName string `json:"nodeName"`
ChainID *int `json:"chainId,omitempty"`
Network *string `json:"network,omitempty"`
IterationIndex *int `json:"iterationIndex,omitempty"`
}

var terminalStatuses = map[string]bool{
"success": true,
"error": true,
Expand Down Expand Up @@ -124,12 +137,42 @@ See also: kh r l, kh r cancel, kh wf run`,
return nil
}

printTransactions := func(status *RunStatusResponse) {
if p.IsJSON() || len(status.TransactionHashes) == 0 {
return
}
fmt.Fprintln(f.IOStreams.Out, "")
fmt.Fprintf(f.IOStreams.Out, "Transactions (%d):\n", len(status.TransactionHashes))
for _, tx := range status.TransactionHashes {
label := tx.NodeName
if tx.IterationIndex != nil {
label = fmt.Sprintf("%s[#%d]", tx.NodeName, *tx.IterationIndex)
}
var chain string
switch {
case tx.Network != nil && *tx.Network != "":
chain = *tx.Network
case tx.ChainID != nil:
chain = strconv.Itoa(*tx.ChainID)
}
if chain != "" {
fmt.Fprintf(f.IOStreams.Out, " %s %s %s\n", tx.Hash, label, chain)
} else {
fmt.Fprintf(f.IOStreams.Out, " %s %s\n", tx.Hash, label)
}
}
}

if !watch {
status, fetchErr := fetchStatus()
if fetchErr != nil {
return fetchErr
}
return printSummary(status)
if printErr := printSummary(status); printErr != nil {
return printErr
}
printTransactions(status)
return nil
}

// Watch mode: poll until terminal status.
Expand Down Expand Up @@ -173,6 +216,7 @@ See also: kh r l, kh r cancel, kh wf run`,
if printErr := printSummary(status); printErr != nil {
return printErr
}
printTransactions(status)
if status.Status == "error" {
if status.ErrorContext != nil {
fmt.Fprintf(f.IOStreams.ErrOut, "Error: %v\n", status.ErrorContext)
Expand Down
230 changes: 220 additions & 10 deletions cmd/run/status_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import (
"testing"

"github.com/keeperhub/cli/cmd/run"
khhttp "github.com/keeperhub/cli/internal/http"
"github.com/keeperhub/cli/internal/config"
khhttp "github.com/keeperhub/cli/internal/http"
"github.com/keeperhub/cli/pkg/cmdutil"
"github.com/keeperhub/cli/pkg/iostreams"
)
Expand Down Expand Up @@ -50,7 +50,7 @@ func makeRunFactory(ios *iostreams.IOStreams, host string) *cmdutil.Factory {
HTTPClient: func() (*khhttp.Client, error) {
return khhttp.NewClient(khhttp.ClientOptions{
Host: host,
IOStreams: ios,
IOStreams: ios,
}), nil
},
}
Expand Down Expand Up @@ -94,7 +94,7 @@ func TestStatusCmd_SingleShot(t *testing.T) {
func TestStatusCmd_JSONOutput(t *testing.T) {
srv := makeStatusServer(t, []map[string]any{
{
"status": "running",
"status": "running",
"nodeStatuses": []map[string]any{},
"progress": map[string]any{
"totalSteps": 2,
Expand Down Expand Up @@ -131,7 +131,7 @@ func TestStatusCmd_JSONOutput(t *testing.T) {
func TestStatusCmd_WatchSucceeds(t *testing.T) {
srv := makeStatusServer(t, []map[string]any{
{
"status": "running",
"status": "running",
"nodeStatuses": []map[string]any{},
"progress": map[string]any{
"totalSteps": 2,
Expand All @@ -144,7 +144,7 @@ func TestStatusCmd_WatchSucceeds(t *testing.T) {
"errorContext": nil,
},
{
"status": "success",
"status": "success",
"nodeStatuses": []map[string]any{},
"progress": map[string]any{
"totalSteps": 2,
Expand Down Expand Up @@ -177,7 +177,7 @@ func TestStatusCmd_WatchSucceeds(t *testing.T) {
func TestStatusCmd_WatchError(t *testing.T) {
srv := makeStatusServer(t, []map[string]any{
{
"status": "error",
"status": "error",
"nodeStatuses": []map[string]any{},
"progress": map[string]any{
"totalSteps": 1,
Expand All @@ -204,7 +204,7 @@ func TestStatusCmd_WatchError(t *testing.T) {
func TestStatusCmd_WatchNonTTY(t *testing.T) {
srv := makeStatusServer(t, []map[string]any{
{
"status": "running",
"status": "running",
"nodeStatuses": []map[string]any{},
"progress": map[string]any{
"totalSteps": 2,
Expand All @@ -217,7 +217,7 @@ func TestStatusCmd_WatchNonTTY(t *testing.T) {
"errorContext": nil,
},
{
"status": "success",
"status": "success",
"nodeStatuses": []map[string]any{},
"progress": map[string]any{
"totalSteps": 2,
Expand Down Expand Up @@ -250,7 +250,7 @@ func TestStatusCmd_WatchNonTTY(t *testing.T) {
func TestStatusCmd_WatchJSONMode(t *testing.T) {
srv := makeStatusServer(t, []map[string]any{
{
"status": "running",
"status": "running",
"nodeStatuses": []map[string]any{},
"progress": map[string]any{
"totalSteps": 1,
Expand All @@ -261,7 +261,7 @@ func TestStatusCmd_WatchJSONMode(t *testing.T) {
"errorContext": nil,
},
{
"status": "success",
"status": "success",
"nodeStatuses": []map[string]any{},
"progress": map[string]any{
"totalSteps": 1,
Expand Down Expand Up @@ -314,3 +314,213 @@ func TestStatusCmd_401AuthHint(t *testing.T) {
}

func strPtr(s string) *string { return &s }

// TestStatusCmd_TxHashes_Empty: when transactionHashes is [] the section is suppressed.
func TestStatusCmd_TxHashes_Empty(t *testing.T) {
srv := makeStatusServer(t, []map[string]any{
{
"status": "success",
"nodeStatuses": []map[string]any{},
"progress": map[string]any{"totalSteps": 1, "completedSteps": 1, "percentage": 100},
"errorContext": nil,
"transactionHashes": []any{},
},
})
defer srv.Close()

ios, buf, _, _ := iostreams.Test()
cmd := run.NewStatusCmd(makeRunFactory(ios, srv.URL))
cmd.SetArgs([]string{"run-abc"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if strings.Contains(buf.String(), "Transactions") {
t.Errorf("did not expect Transactions section for empty array, got: %q", buf.String())
}
}

// TestStatusCmd_TxHashes_NetworkPreferred: network wins over chainId when both present.
func TestStatusCmd_TxHashes_NetworkPreferred(t *testing.T) {
srv := makeStatusServer(t, []map[string]any{
{
"status": "success",
"nodeStatuses": []map[string]any{},
"progress": map[string]any{"totalSteps": 1, "completedSteps": 1, "percentage": 100},
"errorContext": nil,
"transactionHashes": []map[string]any{
{
"hash": "0xabc123",
"nodeId": "n1",
"nodeName": "approveStep",
"chainId": 11155111,
"network": "sepolia",
},
},
},
})
defer srv.Close()

ios, buf, _, _ := iostreams.Test()
cmd := run.NewStatusCmd(makeRunFactory(ios, srv.URL))
cmd.SetArgs([]string{"run-abc"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := buf.String()
if !strings.Contains(out, "Transactions (1):") {
t.Errorf("expected 'Transactions (1):' header, got: %q", out)
}
if !strings.Contains(out, "0xabc123") || !strings.Contains(out, "approveStep") || !strings.Contains(out, "sepolia") {
t.Errorf("expected hash/node/network in row, got: %q", out)
}
if strings.Contains(out, "11155111") {
t.Errorf("network should be preferred over chainId, got: %q", out)
}
}

// TestStatusCmd_TxHashes_ChainIdFallback: chainId renders as a bare number when network is absent.
func TestStatusCmd_TxHashes_ChainIdFallback(t *testing.T) {
srv := makeStatusServer(t, []map[string]any{
{
"status": "success",
"nodeStatuses": []map[string]any{},
"progress": map[string]any{"totalSteps": 1, "completedSteps": 1, "percentage": 100},
"errorContext": nil,
"transactionHashes": []map[string]any{
{"hash": "0xdeadbeef", "nodeId": "n1", "nodeName": "swapStep", "chainId": 1},
},
},
})
defer srv.Close()

ios, buf, _, _ := iostreams.Test()
cmd := run.NewStatusCmd(makeRunFactory(ios, srv.URL))
cmd.SetArgs([]string{"run-abc"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := buf.String()
if !strings.Contains(out, "0xdeadbeef swapStep 1\n") {
t.Errorf("expected bare chainId column, got: %q", out)
}
}

// TestStatusCmd_TxHashes_NoChainColumn: when neither network nor chainId is set, the chain column is omitted.
func TestStatusCmd_TxHashes_NoChainColumn(t *testing.T) {
srv := makeStatusServer(t, []map[string]any{
{
"status": "success",
"nodeStatuses": []map[string]any{},
"progress": map[string]any{"totalSteps": 1, "completedSteps": 1, "percentage": 100},
"errorContext": nil,
"transactionHashes": []map[string]any{
{"hash": "0xcafe", "nodeId": "n1", "nodeName": "stepX"},
},
},
})
defer srv.Close()

ios, buf, _, _ := iostreams.Test()
cmd := run.NewStatusCmd(makeRunFactory(ios, srv.URL))
cmd.SetArgs([]string{"run-abc"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(buf.String(), "0xcafe stepX\n") {
t.Errorf("expected hash + label only when chain info absent, got: %q", buf.String())
}
}

// TestStatusCmd_TxHashes_IterationLabel: iterationIndex appears as [#N] (zero-indexed, raw).
func TestStatusCmd_TxHashes_IterationLabel(t *testing.T) {
srv := makeStatusServer(t, []map[string]any{
{
"status": "success",
"nodeStatuses": []map[string]any{},
"progress": map[string]any{"totalSteps": 1, "completedSteps": 1, "percentage": 100},
"errorContext": nil,
"transactionHashes": []map[string]any{
{"hash": "0x1", "nodeId": "n1", "nodeName": "transferBatch", "iterationIndex": 0, "network": "sepolia"},
{"hash": "0x2", "nodeId": "n1", "nodeName": "transferBatch", "iterationIndex": 1, "network": "sepolia"},
},
},
})
defer srv.Close()

ios, buf, _, _ := iostreams.Test()
cmd := run.NewStatusCmd(makeRunFactory(ios, srv.URL))
cmd.SetArgs([]string{"run-abc"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := buf.String()
if !strings.Contains(out, "Transactions (2):") {
t.Errorf("expected 'Transactions (2):' header, got: %q", out)
}
if !strings.Contains(out, "transferBatch[#0]") || !strings.Contains(out, "transferBatch[#1]") {
t.Errorf("expected [#0] and [#1] labels, got: %q", out)
}
}

// TestStatusCmd_TxHashes_JSONIncludesField: JSON output includes the transactionHashes field
// and the human Transactions section is suppressed.
func TestStatusCmd_TxHashes_JSONIncludesField(t *testing.T) {
srv := makeStatusServer(t, []map[string]any{
{
"status": "success",
"nodeStatuses": []map[string]any{},
"progress": map[string]any{"totalSteps": 1, "completedSteps": 1, "percentage": 100},
"errorContext": nil,
"transactionHashes": []map[string]any{
{"hash": "0xabc", "nodeId": "n1", "nodeName": "swapStep", "network": "sepolia"},
},
},
})
defer srv.Close()

ios, buf, _, _ := iostreams.Test()
cmd := run.NewStatusCmd(makeRunFactory(ios, srv.URL))
cmd.Flags().Bool("json", false, "")
cmd.Flags().String("jq", "", "")
cmd.SetArgs([]string{"run-abc", "--json"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := buf.String()
if !strings.Contains(out, `"transactionHashes"`) {
t.Errorf("expected transactionHashes field in JSON output, got: %q", out)
}
if strings.Contains(out, "Transactions (") {
t.Errorf("human Transactions section should not appear in JSON mode, got: %q", out)
}
}

// TestStatusCmd_TxHashes_WatchTerminalRendersBeforeError: in watch mode with error status,
// the Transactions section appears before the error stderr line.
func TestStatusCmd_TxHashes_WatchTerminalRendersBeforeError(t *testing.T) {
srv := makeStatusServer(t, []map[string]any{
{
"status": "error",
"nodeStatuses": []map[string]any{},
"progress": map[string]any{"totalSteps": 2, "completedSteps": 1, "percentage": 50},
"errorContext": "step 2 reverted",
"transactionHashes": []map[string]any{
{"hash": "0xfeed", "nodeId": "n1", "nodeName": "approveStep", "network": "sepolia"},
},
},
})
defer srv.Close()

ios, buf, errBuf, _ := iostreams.Test()
cmd := run.NewStatusCmd(makeRunFactory(ios, srv.URL))
cmd.SetArgs([]string{"run-abc", "--watch"})
if err := cmd.Execute(); err == nil {
t.Fatal("expected error from watch on error status, got nil")
}
if !strings.Contains(buf.String(), "0xfeed") {
t.Errorf("expected tx hash in stdout, got: %q", buf.String())
}
if !strings.Contains(errBuf.String(), "step 2 reverted") {
t.Errorf("expected errorContext on stderr, got: %q", errBuf.String())
}
}
Loading