Skip to content

Commit 1317153

Browse files
committed
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.
1 parent 56d2863 commit 1317153

2 files changed

Lines changed: 269 additions & 15 deletions

File tree

cmd/run/status.go

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"io"
77
"net/http"
8+
"strconv"
89
"time"
910

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

1617
// RunStatusResponse is the response from GET /api/workflows/executions/{id}/status.
1718
type RunStatusResponse struct {
18-
Status string `json:"status"`
19-
NodeStatuses []NodeStatus `json:"nodeStatuses"`
20-
Progress RunProgress `json:"progress"`
21-
ErrorContext any `json:"errorContext"`
19+
Status string `json:"status"`
20+
NodeStatuses []NodeStatus `json:"nodeStatuses"`
21+
Progress RunProgress `json:"progress"`
22+
ErrorContext any `json:"errorContext"`
23+
TransactionHashes []TransactionHashEntry `json:"transactionHashes"`
2224
}
2325

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

42+
// TransactionHashEntry is an on-chain transaction broadcast by a workflow step.
43+
// IterationIndex is set only for hashes produced inside a For-Each iteration.
44+
type TransactionHashEntry struct {
45+
Hash string `json:"hash"`
46+
NodeID string `json:"nodeId"`
47+
NodeName string `json:"nodeName"`
48+
ChainID *int `json:"chainId,omitempty"`
49+
Network *string `json:"network,omitempty"`
50+
IterationIndex *int `json:"iterationIndex,omitempty"`
51+
}
52+
4053
var terminalStatuses = map[string]bool{
4154
"success": true,
4255
"error": true,
@@ -124,12 +137,42 @@ See also: kh r l, kh r cancel, kh wf run`,
124137
return nil
125138
}
126139

140+
printTransactions := func(status *RunStatusResponse) {
141+
if p.IsJSON() || len(status.TransactionHashes) == 0 {
142+
return
143+
}
144+
fmt.Fprintln(f.IOStreams.Out, "")
145+
fmt.Fprintf(f.IOStreams.Out, "Transactions (%d):\n", len(status.TransactionHashes))
146+
for _, tx := range status.TransactionHashes {
147+
label := tx.NodeName
148+
if tx.IterationIndex != nil {
149+
label = fmt.Sprintf("%s[#%d]", tx.NodeName, *tx.IterationIndex)
150+
}
151+
var chain string
152+
switch {
153+
case tx.Network != nil && *tx.Network != "":
154+
chain = *tx.Network
155+
case tx.ChainID != nil:
156+
chain = strconv.Itoa(*tx.ChainID)
157+
}
158+
if chain != "" {
159+
fmt.Fprintf(f.IOStreams.Out, " %s %s %s\n", tx.Hash, label, chain)
160+
} else {
161+
fmt.Fprintf(f.IOStreams.Out, " %s %s\n", tx.Hash, label)
162+
}
163+
}
164+
}
165+
127166
if !watch {
128167
status, fetchErr := fetchStatus()
129168
if fetchErr != nil {
130169
return fetchErr
131170
}
132-
return printSummary(status)
171+
if printErr := printSummary(status); printErr != nil {
172+
return printErr
173+
}
174+
printTransactions(status)
175+
return nil
133176
}
134177

135178
// Watch mode: poll until terminal status.
@@ -173,6 +216,7 @@ See also: kh r l, kh r cancel, kh wf run`,
173216
if printErr := printSummary(status); printErr != nil {
174217
return printErr
175218
}
219+
printTransactions(status)
176220
if status.Status == "error" {
177221
if status.ErrorContext != nil {
178222
fmt.Fprintf(f.IOStreams.ErrOut, "Error: %v\n", status.ErrorContext)

cmd/run/status_test.go

Lines changed: 220 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ import (
88
"testing"
99

1010
"github.com/keeperhub/cli/cmd/run"
11-
khhttp "github.com/keeperhub/cli/internal/http"
1211
"github.com/keeperhub/cli/internal/config"
12+
khhttp "github.com/keeperhub/cli/internal/http"
1313
"github.com/keeperhub/cli/pkg/cmdutil"
1414
"github.com/keeperhub/cli/pkg/iostreams"
1515
)
@@ -50,7 +50,7 @@ func makeRunFactory(ios *iostreams.IOStreams, host string) *cmdutil.Factory {
5050
HTTPClient: func() (*khhttp.Client, error) {
5151
return khhttp.NewClient(khhttp.ClientOptions{
5252
Host: host,
53-
IOStreams: ios,
53+
IOStreams: ios,
5454
}), nil
5555
},
5656
}
@@ -94,7 +94,7 @@ func TestStatusCmd_SingleShot(t *testing.T) {
9494
func TestStatusCmd_JSONOutput(t *testing.T) {
9595
srv := makeStatusServer(t, []map[string]any{
9696
{
97-
"status": "running",
97+
"status": "running",
9898
"nodeStatuses": []map[string]any{},
9999
"progress": map[string]any{
100100
"totalSteps": 2,
@@ -131,7 +131,7 @@ func TestStatusCmd_JSONOutput(t *testing.T) {
131131
func TestStatusCmd_WatchSucceeds(t *testing.T) {
132132
srv := makeStatusServer(t, []map[string]any{
133133
{
134-
"status": "running",
134+
"status": "running",
135135
"nodeStatuses": []map[string]any{},
136136
"progress": map[string]any{
137137
"totalSteps": 2,
@@ -144,7 +144,7 @@ func TestStatusCmd_WatchSucceeds(t *testing.T) {
144144
"errorContext": nil,
145145
},
146146
{
147-
"status": "success",
147+
"status": "success",
148148
"nodeStatuses": []map[string]any{},
149149
"progress": map[string]any{
150150
"totalSteps": 2,
@@ -177,7 +177,7 @@ func TestStatusCmd_WatchSucceeds(t *testing.T) {
177177
func TestStatusCmd_WatchError(t *testing.T) {
178178
srv := makeStatusServer(t, []map[string]any{
179179
{
180-
"status": "error",
180+
"status": "error",
181181
"nodeStatuses": []map[string]any{},
182182
"progress": map[string]any{
183183
"totalSteps": 1,
@@ -204,7 +204,7 @@ func TestStatusCmd_WatchError(t *testing.T) {
204204
func TestStatusCmd_WatchNonTTY(t *testing.T) {
205205
srv := makeStatusServer(t, []map[string]any{
206206
{
207-
"status": "running",
207+
"status": "running",
208208
"nodeStatuses": []map[string]any{},
209209
"progress": map[string]any{
210210
"totalSteps": 2,
@@ -217,7 +217,7 @@ func TestStatusCmd_WatchNonTTY(t *testing.T) {
217217
"errorContext": nil,
218218
},
219219
{
220-
"status": "success",
220+
"status": "success",
221221
"nodeStatuses": []map[string]any{},
222222
"progress": map[string]any{
223223
"totalSteps": 2,
@@ -250,7 +250,7 @@ func TestStatusCmd_WatchNonTTY(t *testing.T) {
250250
func TestStatusCmd_WatchJSONMode(t *testing.T) {
251251
srv := makeStatusServer(t, []map[string]any{
252252
{
253-
"status": "running",
253+
"status": "running",
254254
"nodeStatuses": []map[string]any{},
255255
"progress": map[string]any{
256256
"totalSteps": 1,
@@ -261,7 +261,7 @@ func TestStatusCmd_WatchJSONMode(t *testing.T) {
261261
"errorContext": nil,
262262
},
263263
{
264-
"status": "success",
264+
"status": "success",
265265
"nodeStatuses": []map[string]any{},
266266
"progress": map[string]any{
267267
"totalSteps": 1,
@@ -314,3 +314,213 @@ func TestStatusCmd_401AuthHint(t *testing.T) {
314314
}
315315

316316
func strPtr(s string) *string { return &s }
317+
318+
// TestStatusCmd_TxHashes_Empty: when transactionHashes is [] the section is suppressed.
319+
func TestStatusCmd_TxHashes_Empty(t *testing.T) {
320+
srv := makeStatusServer(t, []map[string]any{
321+
{
322+
"status": "success",
323+
"nodeStatuses": []map[string]any{},
324+
"progress": map[string]any{"totalSteps": 1, "completedSteps": 1, "percentage": 100},
325+
"errorContext": nil,
326+
"transactionHashes": []any{},
327+
},
328+
})
329+
defer srv.Close()
330+
331+
ios, buf, _, _ := iostreams.Test()
332+
cmd := run.NewStatusCmd(makeRunFactory(ios, srv.URL))
333+
cmd.SetArgs([]string{"run-abc"})
334+
if err := cmd.Execute(); err != nil {
335+
t.Fatalf("unexpected error: %v", err)
336+
}
337+
if strings.Contains(buf.String(), "Transactions") {
338+
t.Errorf("did not expect Transactions section for empty array, got: %q", buf.String())
339+
}
340+
}
341+
342+
// TestStatusCmd_TxHashes_NetworkPreferred: network wins over chainId when both present.
343+
func TestStatusCmd_TxHashes_NetworkPreferred(t *testing.T) {
344+
srv := makeStatusServer(t, []map[string]any{
345+
{
346+
"status": "success",
347+
"nodeStatuses": []map[string]any{},
348+
"progress": map[string]any{"totalSteps": 1, "completedSteps": 1, "percentage": 100},
349+
"errorContext": nil,
350+
"transactionHashes": []map[string]any{
351+
{
352+
"hash": "0xabc123",
353+
"nodeId": "n1",
354+
"nodeName": "approveStep",
355+
"chainId": 11155111,
356+
"network": "sepolia",
357+
},
358+
},
359+
},
360+
})
361+
defer srv.Close()
362+
363+
ios, buf, _, _ := iostreams.Test()
364+
cmd := run.NewStatusCmd(makeRunFactory(ios, srv.URL))
365+
cmd.SetArgs([]string{"run-abc"})
366+
if err := cmd.Execute(); err != nil {
367+
t.Fatalf("unexpected error: %v", err)
368+
}
369+
out := buf.String()
370+
if !strings.Contains(out, "Transactions (1):") {
371+
t.Errorf("expected 'Transactions (1):' header, got: %q", out)
372+
}
373+
if !strings.Contains(out, "0xabc123") || !strings.Contains(out, "approveStep") || !strings.Contains(out, "sepolia") {
374+
t.Errorf("expected hash/node/network in row, got: %q", out)
375+
}
376+
if strings.Contains(out, "11155111") {
377+
t.Errorf("network should be preferred over chainId, got: %q", out)
378+
}
379+
}
380+
381+
// TestStatusCmd_TxHashes_ChainIdFallback: chainId renders as a bare number when network is absent.
382+
func TestStatusCmd_TxHashes_ChainIdFallback(t *testing.T) {
383+
srv := makeStatusServer(t, []map[string]any{
384+
{
385+
"status": "success",
386+
"nodeStatuses": []map[string]any{},
387+
"progress": map[string]any{"totalSteps": 1, "completedSteps": 1, "percentage": 100},
388+
"errorContext": nil,
389+
"transactionHashes": []map[string]any{
390+
{"hash": "0xdeadbeef", "nodeId": "n1", "nodeName": "swapStep", "chainId": 1},
391+
},
392+
},
393+
})
394+
defer srv.Close()
395+
396+
ios, buf, _, _ := iostreams.Test()
397+
cmd := run.NewStatusCmd(makeRunFactory(ios, srv.URL))
398+
cmd.SetArgs([]string{"run-abc"})
399+
if err := cmd.Execute(); err != nil {
400+
t.Fatalf("unexpected error: %v", err)
401+
}
402+
out := buf.String()
403+
if !strings.Contains(out, "0xdeadbeef swapStep 1\n") {
404+
t.Errorf("expected bare chainId column, got: %q", out)
405+
}
406+
}
407+
408+
// TestStatusCmd_TxHashes_NoChainColumn: when neither network nor chainId is set, the chain column is omitted.
409+
func TestStatusCmd_TxHashes_NoChainColumn(t *testing.T) {
410+
srv := makeStatusServer(t, []map[string]any{
411+
{
412+
"status": "success",
413+
"nodeStatuses": []map[string]any{},
414+
"progress": map[string]any{"totalSteps": 1, "completedSteps": 1, "percentage": 100},
415+
"errorContext": nil,
416+
"transactionHashes": []map[string]any{
417+
{"hash": "0xcafe", "nodeId": "n1", "nodeName": "stepX"},
418+
},
419+
},
420+
})
421+
defer srv.Close()
422+
423+
ios, buf, _, _ := iostreams.Test()
424+
cmd := run.NewStatusCmd(makeRunFactory(ios, srv.URL))
425+
cmd.SetArgs([]string{"run-abc"})
426+
if err := cmd.Execute(); err != nil {
427+
t.Fatalf("unexpected error: %v", err)
428+
}
429+
if !strings.Contains(buf.String(), "0xcafe stepX\n") {
430+
t.Errorf("expected hash + label only when chain info absent, got: %q", buf.String())
431+
}
432+
}
433+
434+
// TestStatusCmd_TxHashes_IterationLabel: iterationIndex appears as [#N] (zero-indexed, raw).
435+
func TestStatusCmd_TxHashes_IterationLabel(t *testing.T) {
436+
srv := makeStatusServer(t, []map[string]any{
437+
{
438+
"status": "success",
439+
"nodeStatuses": []map[string]any{},
440+
"progress": map[string]any{"totalSteps": 1, "completedSteps": 1, "percentage": 100},
441+
"errorContext": nil,
442+
"transactionHashes": []map[string]any{
443+
{"hash": "0x1", "nodeId": "n1", "nodeName": "transferBatch", "iterationIndex": 0, "network": "sepolia"},
444+
{"hash": "0x2", "nodeId": "n1", "nodeName": "transferBatch", "iterationIndex": 1, "network": "sepolia"},
445+
},
446+
},
447+
})
448+
defer srv.Close()
449+
450+
ios, buf, _, _ := iostreams.Test()
451+
cmd := run.NewStatusCmd(makeRunFactory(ios, srv.URL))
452+
cmd.SetArgs([]string{"run-abc"})
453+
if err := cmd.Execute(); err != nil {
454+
t.Fatalf("unexpected error: %v", err)
455+
}
456+
out := buf.String()
457+
if !strings.Contains(out, "Transactions (2):") {
458+
t.Errorf("expected 'Transactions (2):' header, got: %q", out)
459+
}
460+
if !strings.Contains(out, "transferBatch[#0]") || !strings.Contains(out, "transferBatch[#1]") {
461+
t.Errorf("expected [#0] and [#1] labels, got: %q", out)
462+
}
463+
}
464+
465+
// TestStatusCmd_TxHashes_JSONIncludesField: JSON output includes the transactionHashes field
466+
// and the human Transactions section is suppressed.
467+
func TestStatusCmd_TxHashes_JSONIncludesField(t *testing.T) {
468+
srv := makeStatusServer(t, []map[string]any{
469+
{
470+
"status": "success",
471+
"nodeStatuses": []map[string]any{},
472+
"progress": map[string]any{"totalSteps": 1, "completedSteps": 1, "percentage": 100},
473+
"errorContext": nil,
474+
"transactionHashes": []map[string]any{
475+
{"hash": "0xabc", "nodeId": "n1", "nodeName": "swapStep", "network": "sepolia"},
476+
},
477+
},
478+
})
479+
defer srv.Close()
480+
481+
ios, buf, _, _ := iostreams.Test()
482+
cmd := run.NewStatusCmd(makeRunFactory(ios, srv.URL))
483+
cmd.Flags().Bool("json", false, "")
484+
cmd.Flags().String("jq", "", "")
485+
cmd.SetArgs([]string{"run-abc", "--json"})
486+
if err := cmd.Execute(); err != nil {
487+
t.Fatalf("unexpected error: %v", err)
488+
}
489+
out := buf.String()
490+
if !strings.Contains(out, `"transactionHashes"`) {
491+
t.Errorf("expected transactionHashes field in JSON output, got: %q", out)
492+
}
493+
if strings.Contains(out, "Transactions (") {
494+
t.Errorf("human Transactions section should not appear in JSON mode, got: %q", out)
495+
}
496+
}
497+
498+
// TestStatusCmd_TxHashes_WatchTerminalRendersBeforeError: in watch mode with error status,
499+
// the Transactions section appears before the error stderr line.
500+
func TestStatusCmd_TxHashes_WatchTerminalRendersBeforeError(t *testing.T) {
501+
srv := makeStatusServer(t, []map[string]any{
502+
{
503+
"status": "error",
504+
"nodeStatuses": []map[string]any{},
505+
"progress": map[string]any{"totalSteps": 2, "completedSteps": 1, "percentage": 50},
506+
"errorContext": "step 2 reverted",
507+
"transactionHashes": []map[string]any{
508+
{"hash": "0xfeed", "nodeId": "n1", "nodeName": "approveStep", "network": "sepolia"},
509+
},
510+
},
511+
})
512+
defer srv.Close()
513+
514+
ios, buf, errBuf, _ := iostreams.Test()
515+
cmd := run.NewStatusCmd(makeRunFactory(ios, srv.URL))
516+
cmd.SetArgs([]string{"run-abc", "--watch"})
517+
if err := cmd.Execute(); err == nil {
518+
t.Fatal("expected error from watch on error status, got nil")
519+
}
520+
if !strings.Contains(buf.String(), "0xfeed") {
521+
t.Errorf("expected tx hash in stdout, got: %q", buf.String())
522+
}
523+
if !strings.Contains(errBuf.String(), "step 2 reverted") {
524+
t.Errorf("expected errorContext on stderr, got: %q", errBuf.String())
525+
}
526+
}

0 commit comments

Comments
 (0)