From 13171536331518477adf4f2d7004869def8985d7 Mon Sep 17 00:00:00 2001 From: Jacob Sussmilch Date: Tue, 12 May 2026 14:02:05 +1000 Subject: [PATCH] feat: KEEP-541 surface transactionHashes in run status Add the top-level transactionHashes array (shipped by backend in KEEP-470) to RunStatusResponse and render a Transactions section after the summary in non-JSON output. Section is suppressed when empty and in JSON mode; JSON mode surfaces the field through the struct. Per-row layout: hash, node label (with [#N] when produced inside a For-Each iteration), then network preferred over chainId; chain column omitted entirely when both are absent. Watch mode prints the section between the terminal summary and the stderr error line. --- cmd/run/status.go | 54 +++++++++- cmd/run/status_test.go | 230 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 269 insertions(+), 15 deletions(-) diff --git a/cmd/run/status.go b/cmd/run/status.go index b2d9753..a47088a 100644 --- a/cmd/run/status.go +++ b/cmd/run/status.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "net/http" + "strconv" "time" khhttp "github.com/keeperhub/cli/internal/http" @@ -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. @@ -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, @@ -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. @@ -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) diff --git a/cmd/run/status_test.go b/cmd/run/status_test.go index f6decf1..d456573 100644 --- a/cmd/run/status_test.go +++ b/cmd/run/status_test.go @@ -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" ) @@ -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 }, } @@ -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, @@ -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, @@ -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, @@ -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, @@ -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, @@ -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, @@ -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, @@ -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, @@ -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()) + } +}