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
31 changes: 3 additions & 28 deletions internal/temporalcli/commands.activity.go
Original file line number Diff line number Diff line change
Expand Up @@ -470,35 +470,10 @@ func printActivityDescription(cctx *CommandContext, resp *workflowservice.Descri
if err := cctx.Printer.PrintStructured(d, printer.StructuredOptions{}); err != nil {
return err
}
return printActivityCallbacks(cctx, resp.GetCallbacks())
}

func printActivityCallbacks(cctx *CommandContext, callbacks []*activitypb.CallbackInfo) error {
if len(callbacks) == 0 {
return nil
if err := printLinks(cctx, info.GetLinks()); err != nil {
return err
}
rows := make([]struct {
State string
Attempt int32
RegistrationTime time.Time `cli:",cardOmitEmpty"`
NextAttemptScheduleTime time.Time `cli:",cardOmitEmpty"`
BlockedReason string `cli:",cardOmitEmpty"`
LastAttemptFailure string `cli:",cardOmitEmpty"`
}, len(callbacks))
for i, cb := range callbacks {
info := cb.GetInfo()
rows[i].State = info.GetState().String()
rows[i].Attempt = info.GetAttempt()
rows[i].RegistrationTime = timestampToTime(info.GetRegistrationTime())
rows[i].NextAttemptScheduleTime = timestampToTime(info.GetNextAttemptScheduleTime())
rows[i].BlockedReason = info.GetBlockedReason()
if f := info.GetLastAttemptFailure(); f != nil {
rows[i].LastAttemptFailure = cctx.MarshalFriendlyFailureBodyText(f, " ")
}
}
cctx.Printer.Println()
cctx.Printer.Println(color.MagentaString("Callbacks:"))
return cctx.Printer.PrintStructured(rows, printer.StructuredOptions{Table: &printer.TableOptions{}})
return printCallbacks(cctx, resp.GetCallbacks())
}

func (c *TemporalActivityListCommand) run(cctx *CommandContext, args []string) error {
Expand Down
116 changes: 116 additions & 0 deletions internal/temporalcli/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,14 @@ import (
"github.com/temporalio/cli/cliext"
"github.com/temporalio/cli/internal/printer"
"github.com/temporalio/ui-server/v2/server/version"
activitypb "go.temporal.io/api/activity/v1"
"go.temporal.io/api/common/v1"
commonpb "go.temporal.io/api/common/v1"
"go.temporal.io/api/enums/v1"
Comment on lines +25 to +28

@Quinn-With-Two-Ns Quinn-With-Two-Ns Aug 5, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't my change, it should be fixed but I think it should be in a separate PR since this will touch a lot of files

"go.temporal.io/api/failure/v1"
"go.temporal.io/api/temporalnexus"
"go.temporal.io/api/temporalproto"
workflowpb "go.temporal.io/api/workflow/v1"
"go.temporal.io/sdk/contrib/envconfig"
"go.temporal.io/sdk/converter"
"go.temporal.io/sdk/temporal"
Expand Down Expand Up @@ -606,6 +610,118 @@ func timestampToTime(t *timestamppb.Timestamp) time.Time {
return t.AsTime()
}

// nexusLinkStrings converts Temporal common links into their Nexus link URL
// string representations, skipping any link variant that has no Nexus
// equivalent. The conversion helpers live in go.temporal.io/api/temporalnexus
// (previously in the Go SDK's temporalnexus package).
func nexusLinkStrings(links []*commonpb.Link) []string {
var out []string
for _, link := range links {
if nexusLink, ok := temporalnexus.CommonLinkToNexusLink(link); ok {
out = append(out, nexusLink.GetUrl())
}
}
return out
}

// printLinks renders Temporal common links as a standalone Nexus links section.
// Nothing is printed when none of the links have a Nexus representation.
func printLinks(cctx *CommandContext, links []*commonpb.Link) error {
urls := nexusLinkStrings(links)
if len(urls) == 0 {
return nil
}
rows := make([]struct {
Link string
}, len(urls))
for i, url := range urls {
rows[i].Link = url
}
cctx.Printer.Println()
cctx.Printer.Println(color.MagentaString("Links: %v", len(urls)))
cctx.Printer.Println()
return cctx.Printer.PrintStructured(rows, printer.StructuredOptions{})
}

// callbackInfoForPrint is the subset of accessors shared by workflow.v1.CallbackInfo
// and callback.v1.CallbackInfo (the latter nested inside activity.v1.CallbackInfo).
// It lets the workflow and activity describe commands render callbacks through a
// single code path (printCallbacks).
type callbackInfoForPrint interface {
GetCallback() *commonpb.Callback
GetState() enums.CallbackState
GetAttempt() int32
GetRegistrationTime() *timestamppb.Timestamp
GetNextAttemptScheduleTime() *timestamppb.Timestamp
GetLastAttemptCompleteTime() *timestamppb.Timestamp
GetLastAttemptFailure() *failure.Failure
GetBlockedReason() string
}

type callbackForPrint interface {
*workflowpb.CallbackInfo | *activitypb.CallbackInfo
}

func normalizeCallbackForPrint[T callbackForPrint](cb T) (callbackInfoForPrint, string) {
switch cb := any(cb).(type) {
case *workflowpb.CallbackInfo:
trigger := "Unknown"
if cb.GetTrigger().GetWorkflowClosed() != nil {
trigger = "WorkflowClosed"
}
return cb, trigger
case *activitypb.CallbackInfo:
trigger := "Unknown"
if cb.GetTrigger().GetActivityClosed() != nil {
trigger = "ActivityClosed"
}
return cb.GetInfo(), trigger
default:
panic("unsupported callback type")
}
}

// printCallbacks renders the "Callbacks: N" section shared by the workflow and
// activity describe commands. Nothing is printed when there are no callbacks.
// This is only reached for text output; JSON output prints the raw describe
// response instead.
func printCallbacks[T callbackForPrint](cctx *CommandContext, callbacks []T) error {
if len(callbacks) == 0 {
return nil
}
rows := make([]struct {
URL string
Links []string `cli:",cardOmitEmpty"`
Trigger string
State enums.CallbackState
Attempt int32
RegistrationTime time.Time `cli:",cardOmitEmpty"`
NextAttemptScheduleTime time.Time `cli:",cardOmitEmpty"`
LastAttemptCompleteTime time.Time `cli:",cardOmitEmpty"`
BlockedReason string `cli:",cardOmitEmpty"`
LastAttemptFailure string `cli:",cardOmitEmpty"`
}, len(callbacks))
for i, cb := range callbacks {
ci, trigger := normalizeCallbackForPrint(cb)
rows[i].URL = ci.GetCallback().GetNexus().GetUrl()
rows[i].Links = nexusLinkStrings(ci.GetCallback().GetLinks())
rows[i].Trigger = trigger
rows[i].State = ci.GetState()
rows[i].Attempt = ci.GetAttempt()
rows[i].RegistrationTime = timestampToTime(ci.GetRegistrationTime())
rows[i].NextAttemptScheduleTime = timestampToTime(ci.GetNextAttemptScheduleTime())
rows[i].LastAttemptCompleteTime = timestampToTime(ci.GetLastAttemptCompleteTime())
rows[i].BlockedReason = ci.GetBlockedReason()
if f := ci.GetLastAttemptFailure(); f != nil {
rows[i].LastAttemptFailure = cctx.MarshalFriendlyFailureBodyText(f, " ")
}
}
cctx.Printer.Println()
cctx.Printer.Println(color.MagentaString("Callbacks: %v", len(callbacks)))
cctx.Printer.Println()
return cctx.Printer.PrintStructured(rows, printer.StructuredOptions{})
}

type nopWriter struct{}

func (nopWriter) Write(b []byte) (int, error) { return len(b), nil }
Expand Down
177 changes: 177 additions & 0 deletions internal/temporalcli/commands.link_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
package temporalcli

import (
"bytes"
"testing"

"github.com/stretchr/testify/require"
"github.com/temporalio/cli/internal/printer"
activitypb "go.temporal.io/api/activity/v1"
callbackpb "go.temporal.io/api/callback/v1"
commonpb "go.temporal.io/api/common/v1"
enumspb "go.temporal.io/api/enums/v1"
nexuspb "go.temporal.io/api/nexus/v1"
"go.temporal.io/api/workflowservice/v1"
"go.temporal.io/sdk/client"
)

func workflowEventLink() *commonpb.Link {
return &commonpb.Link{
Variant: &commonpb.Link_WorkflowEvent_{
WorkflowEvent: &commonpb.Link_WorkflowEvent{
Namespace: "ns",
WorkflowId: "wf-id",
RunId: "run-id",
Reference: &commonpb.Link_WorkflowEvent_EventRef{
EventRef: &commonpb.Link_WorkflowEvent_EventReference{
EventId: 1,
EventType: enumspb.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED,
},
},
},
},
}
}

// TestNexusLinkStrings verifies that nexusLinkStrings converts supported common
// links into their Nexus link URLs and skips variants that have no Nexus
// equivalent.
func TestNexusLinkStrings(t *testing.T) {
activityLink := &commonpb.Link{
Variant: &commonpb.Link_Activity_{
Activity: &commonpb.Link_Activity{
Namespace: "ns",
ActivityId: "act-id",
RunId: "run-id",
},
},
}
nexusOperationLink := &commonpb.Link{
Variant: &commonpb.Link_NexusOperation_{
NexusOperation: &commonpb.Link_NexusOperation{
Namespace: "ns",
OperationId: "op-id",
RunId: "run-id",
},
},
}
workflowLink := &commonpb.Link{
Variant: &commonpb.Link_Workflow_{
Workflow: &commonpb.Link_Workflow{
Namespace: "ns",
WorkflowId: "wf-id",
RunId: "run-id",
Reason: "started activity",
},
},
}
// A link with an unset (unsupported) variant, which must be skipped.
otherLink := &commonpb.Link{}

t.Run("nil and empty", func(t *testing.T) {
require.Nil(t, nexusLinkStrings(nil))
require.Nil(t, nexusLinkStrings([]*commonpb.Link{}))
})

t.Run("workflow event link", func(t *testing.T) {
got := nexusLinkStrings([]*commonpb.Link{workflowEventLink()})
require.Equal(t, []string{
"temporal:///namespaces/ns/workflows/wf-id/run-id/history?eventID=1&eventType=WorkflowExecutionStarted&referenceType=EventReference",
}, got)
})

t.Run("activity and nexus operation links", func(t *testing.T) {
got := nexusLinkStrings([]*commonpb.Link{activityLink, nexusOperationLink})
require.Len(t, got, 2)
require.Contains(t, got[0], "temporal://")
require.Contains(t, got[0], "act-id")
require.Contains(t, got[1], "temporal://")
require.Contains(t, got[1], "op-id")
})

t.Run("workflow link", func(t *testing.T) {
got := nexusLinkStrings([]*commonpb.Link{workflowLink})
require.Equal(t, []string{
"temporal:///namespaces/ns/workflows/wf-id/run-id?reason=started+activity",
}, got)
})

t.Run("skips unsupported variants and preserves order", func(t *testing.T) {
got := nexusLinkStrings([]*commonpb.Link{otherLink, workflowEventLink(), otherLink})
require.Len(t, got, 1)
require.Contains(t, got[0], "wf-id")
})
}

// TestPrintActivityDescription_LinksAndCallbacks verifies that activity describe
// renders the activity's Nexus links and its callbacks (including callback URL,
// links, and trigger) like workflow describe does.
func TestPrintActivityDescription_LinksAndCallbacks(t *testing.T) {
var buf bytes.Buffer
cctx := &CommandContext{Printer: &printer.Printer{Output: &buf}}

resp := &workflowservice.DescribeActivityExecutionResponse{
Info: &activitypb.ActivityExecutionInfo{
ActivityId: "act-1",
RunId: "run-1",
ActivityType: &commonpb.ActivityType{Name: "MyActivity"},
Status: enumspb.ACTIVITY_EXECUTION_STATUS_RUNNING,
TaskQueue: "tq",
Attempt: 1,
Links: []*commonpb.Link{workflowEventLink()},
},
Callbacks: []*activitypb.CallbackInfo{
{
Trigger: &activitypb.CallbackInfo_Trigger{
Variant: &activitypb.CallbackInfo_Trigger_ActivityClosed{
ActivityClosed: &activitypb.CallbackInfo_ActivityClosed{},
},
},
Info: &callbackpb.CallbackInfo{
Callback: &commonpb.Callback{
Variant: &commonpb.Callback_Nexus_{
Nexus: &commonpb.Callback_Nexus{Url: "http://callback.example/cb"},
},
Links: []*commonpb.Link{workflowEventLink()},
},
State: enumspb.CALLBACK_STATE_SUCCEEDED,
Attempt: 2,
},
},
},
}

require.NoError(t, printActivityDescription(cctx, resp))
out := buf.String()

// Execution info.
require.Contains(t, out, "act-1")
require.Contains(t, out, "MyActivity")
// Top-level activity Links section.
require.Contains(t, out, "Links: 1")
require.Contains(t, out, "temporal:///namespaces/ns/workflows/wf-id/run-id/history")
// Callbacks section with URL, trigger, link, and state.
require.Contains(t, out, "Callbacks: 1")
require.Contains(t, out, "http://callback.example/cb")
require.Contains(t, out, "ActivityClosed")
require.Contains(t, out, "Succeeded")
}

func TestPrintNexusOperationDescription_Links(t *testing.T) {
var buf bytes.Buffer
cctx := &CommandContext{Printer: &printer.Printer{Output: &buf}}

desc := &client.NexusOperationExecutionDescription{
RawInfo: &nexuspb.NexusOperationExecutionInfo{
Links: []*commonpb.Link{workflowEventLink()},
},
}
desc.OperationID = "op-1"
desc.OperationRunID = "run-1"

require.NoError(t, printNexusOperationDescription(cctx, desc))
out := buf.String()
require.Contains(t, out, "op-1")
require.Contains(t, out, "Links: 1")
require.Contains(t, out, "temporal:///namespaces/ns/workflows/wf-id/run-id/history")
}
5 changes: 4 additions & 1 deletion internal/temporalcli/commands.nexus_operation.go
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,10 @@ func printNexusOperationDescription(cctx *CommandContext, desc *client.NexusOper
Identity: desc.Identity,
Summary: summary,
}
return cctx.Printer.PrintStructured(d, printer.StructuredOptions{})
if err := cctx.Printer.PrintStructured(d, printer.StructuredOptions{}); err != nil {
return err
}
return printLinks(cctx, desc.RawInfo.GetLinks())
}

func (c *TemporalNexusOperationCancelCommand) run(cctx *CommandContext, args []string) error {
Expand Down
Loading