feat: expose bounded workflow activation and signal history

This commit is contained in:
2026-09-18 07:56:18 +08:00
parent ae0d08584a
commit 17a8e0126c
35 changed files with 1756 additions and 59 deletions

View File

@@ -167,6 +167,29 @@ func run() error {
return errors.New("incomplete workflow task sources")
}
fmt.Printf("workflow_sources_verified=%d fixed_version=%s\n", verified, accepted.Instance.Version)
history, err := client.ListWorkflowActivations(ctx, accepted.Instance.ID, "receipt", orbit.HistoryPageRequest{Limit: 20})
if err != nil {
return err
}
if len(history.Items) != 1 {
return errors.New("unexpected receipt activation history")
}
historical, err := client.GetWorkflowActivation(ctx, accepted.Instance.ID, "receipt", history.Items[0].ID)
if err != nil {
return err
}
if historical.Wait == nil || historical.Wait.WinnerSignalID == nil || *historical.Wait.WinnerSignalID != sent.Signal.ID {
return errors.New("historical signal winner missing")
}
signals, err := client.ListWorkflowSignals(ctx, accepted.Instance.ID, orbit.HistoryPageRequest{Limit: 20})
if err != nil {
return err
}
if len(signals.Items) != 1 || signals.Items[0].State != "CONSUMED" || signals.Items[0].ID != sent.Signal.ID {
return errors.New("signal history missing")
}
fmt.Println("workflow_history_verified=true signal_state=CONSUMED")
stop()
err = <-done
waited = true

173
sdk/go/workflow_history.go Normal file
View File

@@ -0,0 +1,173 @@
package orbit
import (
"context"
"errors"
)
func workflowActivationPath(id, key string, activation ...string) (string, error) {
if !validID(id) || !workflowVersionName(key) {
return "", errors.New("invalid workflow step identity")
}
path := "/v1/workflows/" + id + "/steps/" + key + "/activations"
for _, v := range activation {
if !validID(v) {
return "", errors.New("invalid activation id")
}
path += "/" + v
}
return path, nil
}
func workflowSignalPath(id string, signal ...string) (string, error) {
if !validID(id) {
return "", errors.New("invalid workflow id")
}
path := "/v1/workflows/" + id + "/signals"
for _, v := range signal {
if !validID(v) {
return "", errors.New("invalid signal id")
}
path += "/" + v
}
return path, nil
}
func historyResponseSize(n int, in HistoryPageRequest) bool {
limit := in.Limit
if limit == 0 {
limit = 20
}
return n <= limit
}
// ListWorkflowActivations 返回指定逻辑步骤的各次激活摘要,代次倒序;跨页状态可继续变化。
func (c *Client) ListWorkflowActivations(ctx context.Context, id, key string, in HistoryPageRequest) (WorkflowActivationPage, error) {
var out WorkflowActivationPage
path, err := workflowActivationPath(id, key)
if err != nil {
return out, err
}
query, err := historyPageQuery(in)
if err != nil {
return out, err
}
if err = c.call(ctx, "GET", path+query, "", nil, &out); err != nil {
return WorkflowActivationPage{}, err
}
if out.InstanceID != id || !validID(out.StepID) || out.Revision == 0 || out.Items == nil || !historyResponseSize(len(out.Items), in) {
return WorkflowActivationPage{}, errors.New("invalid activation page")
}
for i, a := range out.Items {
if !validID(a.ID) || a.StepID != out.StepID || a.Sequence == 0 || a.Revision == 0 || i > 0 && a.Sequence >= out.Items[i-1].Sequence {
return WorkflowActivationPage{}, errors.New("invalid activation summary")
}
}
return out, nil
}
// GetWorkflowActivation 明确读取给定历史激活,Step仍为逻辑步骤当前视图,不能用它替代历史Activation。
func (c *Client) GetWorkflowActivation(ctx context.Context, id, key, activation string) (WorkflowActivationDetail, error) {
var out WorkflowActivationDetail
path, err := workflowActivationPath(id, key, activation)
if err != nil {
return out, err
}
if err = c.call(ctx, "GET", path, "", nil, &out); err != nil {
return WorkflowActivationDetail{}, err
}
if out.InstanceID != id || out.Step.Key != key || out.Activation.ID != activation || !validID(out.Step.ID) || out.Activation.StepID != out.Step.ID || out.Activation.Sequence == 0 || out.Revision == 0 {
return WorkflowActivationDetail{}, errors.New("activation detail identity mismatch")
}
return out, nil
}
// ListWorkflowPollRounds 只读取一个激活内的查询轮次;人工恢复的同一业务轮可分属不同激活。
func (c *Client) ListWorkflowPollRounds(ctx context.Context, id, key, activation string, in HistoryPageRequest) (WorkflowPollRoundPage, error) {
var out WorkflowPollRoundPage
path, err := workflowActivationPath(id, key, activation)
if err != nil {
return out, err
}
query, err := historyPageQuery(in)
if err != nil {
return out, err
}
if err = c.call(ctx, "GET", path+"/rounds"+query, "", nil, &out); err != nil {
return WorkflowPollRoundPage{}, err
}
if out.InstanceID != id || out.ActivationID != activation || out.Revision == 0 || out.Items == nil || !historyResponseSize(len(out.Items), in) {
return WorkflowPollRoundPage{}, errors.New("invalid poll round page")
}
for i, r := range out.Items {
if !validID(r.ID) || !validID(r.TaskID) || r.ActivationID != activation || r.Round == 0 || i > 0 && r.Round >= out.Items[i-1].Round {
return WorkflowPollRoundPage{}, errors.New("invalid poll round summary")
}
}
return out, nil
}
// ListWorkflowSignals 不携带信号正文;单条原始内容用GetWorkflowSignal读取。
func (c *Client) ListWorkflowSignals(ctx context.Context, id string, in HistoryPageRequest) (WorkflowSignalPage, error) {
var out WorkflowSignalPage
path, err := workflowSignalPath(id)
if err != nil {
return out, err
}
query, err := historyPageQuery(in)
if err != nil {
return out, err
}
if err = c.call(ctx, "GET", path+query, "", nil, &out); err != nil {
return WorkflowSignalPage{}, err
}
if out.WorkflowID != id || out.Revision == 0 || out.Items == nil || !historyResponseSize(len(out.Items), in) {
return WorkflowSignalPage{}, errors.New("invalid signal page")
}
for _, v := range out.Items {
if !validID(v.ID) || v.WorkflowID != id {
return WorkflowSignalPage{}, errors.New("signal summary identity mismatch")
}
}
return out, nil
}
// ListWorkflowSignalOperations 需要独立workflows:audit:read权限,只返回信号资源的操作审计摘要。
func (c *Client) ListWorkflowSignalOperations(ctx context.Context, id, signal string, in HistoryPageRequest) (WorkflowSignalOperationPage, error) {
var out WorkflowSignalOperationPage
path, err := workflowSignalPath(id, signal)
if err != nil {
return out, err
}
query, err := historyPageQuery(in)
if err != nil {
return out, err
}
if err = c.call(ctx, "GET", path+"/operations"+query, "", nil, &out); err != nil {
return WorkflowSignalOperationPage{}, err
}
if out.Items == nil || !historyResponseSize(len(out.Items), in) {
return WorkflowSignalOperationPage{}, errors.New("invalid signal operation page")
}
for _, v := range out.Items {
if !validID(v.ID) || v.WorkflowID != id || v.SignalID != signal {
return WorkflowSignalOperationPage{}, errors.New("signal operation identity mismatch")
}
}
return out, nil
}
func (c *Client) GetWorkflowSignalOperation(ctx context.Context, id, signal, operation string) (WorkflowSignalOperation, error) {
var out WorkflowSignalOperation
path, err := workflowSignalPath(id, signal)
if err != nil {
return out, err
}
if !validID(operation) {
return out, errors.New("invalid signal operation id")
}
if err = c.call(ctx, "GET", path+"/operations/"+operation, "", nil, &out); err != nil {
return WorkflowSignalOperation{}, err
}
if out.ID != operation || out.WorkflowID != id || out.SignalID != signal {
return WorkflowSignalOperation{}, errors.New("signal operation detail identity mismatch")
}
return out, nil
}

View File

@@ -0,0 +1,89 @@
package orbit
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
)
func TestWorkflowHistoryIdentityBoundsAndResponseValidation(t *testing.T) {
const id = "11111111111111111111111111111111"
const child = "22222222222222222222222222222222"
ctx := context.Background()
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
if r.Method != "GET" || r.Header.Get("Idempotency-Key") != "" || r.Header.Get("Authorization") != "Bearer test-token" {
t.Error("invalid read request")
}
if r.URL.Query().Get("cursor") != "opaque+cursor/=" || r.URL.Query().Get("limit") != "1" {
t.Error("query changed")
}
// 合法响应信封中的错误父资源,SDK不得将其作为请求资源的数据返回。
json.NewEncoder(w).Encode(map[string]any{"request_id": id, "data": map[string]any{"instance_id": child, "workflow_id": child, "step_id": child, "revision": "1", "items": []any{}}})
}))
defer server.Close()
c, err := NewClient(server.URL, Options{Token: "test-token"})
if err != nil {
t.Fatal(err)
}
for _, bad := range []string{"../other", strings.Repeat("A", 32), "a?x=b"} {
if _, err = c.ListWorkflowActivations(ctx, bad, "work", HistoryPageRequest{}); err == nil {
t.Fatal("bad workflow accepted")
}
if _, err = c.GetWorkflowActivation(ctx, id, "work", bad); err == nil {
t.Fatal("bad activation accepted")
}
if _, err = c.ListWorkflowPollRounds(ctx, id, "work", bad, HistoryPageRequest{}); err == nil {
t.Fatal("bad round scope accepted")
}
if _, err = c.ListWorkflowSignals(ctx, bad, HistoryPageRequest{}); err == nil {
t.Fatal("bad signal parent accepted")
}
if _, err = c.ListWorkflowSignalOperations(ctx, id, bad, HistoryPageRequest{}); err == nil {
t.Fatal("bad signal accepted")
}
if _, err = c.GetWorkflowSignalOperation(ctx, id, child, bad); err == nil {
t.Fatal("bad operation accepted")
}
}
for _, key := range []string{"$root", "../other", "", strings.Repeat("x", 65)} {
if _, err = c.ListWorkflowActivations(ctx, id, key, HistoryPageRequest{}); err == nil {
t.Fatal("bad key accepted")
}
}
for _, in := range []HistoryPageRequest{{Limit: -1}, {Limit: 101}, {Cursor: strings.Repeat("x", 1025)}} {
if _, err = c.ListWorkflowActivations(ctx, id, "work", in); err == nil {
t.Fatal("bad activation page")
}
if _, err = c.ListWorkflowPollRounds(ctx, id, "work", child, in); err == nil {
t.Fatal("bad round page")
}
if _, err = c.ListWorkflowSignals(ctx, id, in); err == nil {
t.Fatal("bad signal page")
}
if _, err = c.ListWorkflowSignalOperations(ctx, id, child, in); err == nil {
t.Fatal("bad operation page")
}
}
if calls.Load() != 0 {
t.Fatal("invalid arguments reached transport")
}
page := HistoryPageRequest{Limit: 1, Cursor: "opaque+cursor/="}
if _, err = c.ListWorkflowActivations(ctx, id, "work", page); err == nil {
t.Fatal("foreign activation page accepted")
}
if _, err = c.ListWorkflowPollRounds(ctx, id, "work", child, page); err == nil {
t.Fatal("foreign round page accepted")
}
if _, err = c.ListWorkflowSignals(ctx, id, page); err == nil {
t.Fatal("foreign signal page accepted")
}
if calls.Load() != 3 {
t.Fatal("unexpected automatic read retry")
}
}

View File

@@ -0,0 +1,92 @@
package orbit
import (
"encoding/json"
"time"
)
// WorkflowActivationSummary 描述指定历史激活的当前记录,不携带大输入/结果或执行凭据。
type WorkflowActivationSummary struct {
ID string `json:"id"` // ID 激活身份,人工恢复时新建且保留原记录。
StepID string `json:"step_id"` // StepID 本激活所属逻辑步骤。
Sequence uint32 `json:"sequence"` // Sequence 该步骤的激活代次,和POLL业务轮次分开。
State string `json:"state"` // State 此激活状态,不代表实例当前状态。
Revision uint64 `json:"revision,string"` // Revision 此激活记录的版本,保留整数精度。
TaskID *string `json:"task_id,omitempty"` // TaskID TASK的直接任务;POLL从轮次列表查询。
CreatedAt time.Time `json:"created_at"` // CreatedAt 激活创建时间,不代表恢复获得新截止。
UpdatedAt time.Time `json:"updated_at"` // UpdatedAt 此记录最后变更时间。
CompletedAt *time.Time `json:"completed_at,omitempty"` // CompletedAt 此激活已保存的结束时间;未记录时省略。
}
type WorkflowActivationPage struct {
InstanceID string `json:"instance_id"` // InstanceID 已授权的所属实例。
Revision uint64 `json:"revision,string"` // Revision 本页读取时的实例版本;跨页不保证同一快照。
StepID string `json:"step_id"` // StepID 已核验的逻辑步骤。
CurrentActivationID *string `json:"current_activation_id,omitempty"` // CurrentActivationID 本页读取时的当前激活,未激活时省略。
Items []WorkflowActivationSummary `json:"items"` // Items 按代次倒序的有界摘要,空页为[]。
NextCursor string `json:"next_cursor,omitempty"` // NextCursor 绑定实例、步骤和查询类型的后续位置。
}
type WorkflowActivationDetail struct {
InstanceID string `json:"instance_id"` // InstanceID 所属实例。
Revision uint64 `json:"revision,string"` // Revision 读取详情时的实例版本。
Step WorkflowStepStatus `json:"step"` // Step 逻辑步骤的当前视图,不能替代Activation中的历史代次。
Activation WorkflowActivationSummary `json:"activation"` // Activation 指定的历史激活,不自动改为当前激活。
Input json.RawMessage `json:"input"` // Input 此激活原输入,显式null保留。
Output json.RawMessage `json:"output,omitempty"` // Output 此激活当前保存的结果,缺失与显式null不同。
Poll *WorkflowPollStatus `json:"poll,omitempty"` // Poll 此激活的查询预算及最后任务,只用于POLL。
Wait *WorkflowWaitStatus `json:"wait,omitempty"` // Wait 此激活的等待记录,恢复不修改旧记录的截止。
}
type WorkflowPollRoundSummary struct {
ID string `json:"id"` // ID 该任务唯一对应的轮次记录。
ActivationID string `json:"activation_id"` // ActivationID 产生本次查询的激活。
Round uint32 `json:"round"` // Round 从首次激活累计的业务轮编号;恢复同轮不重置。
TaskID string `json:"task_id"` // TaskID 该轮Pulse任务,尝试和结果由任务接口读取。
CreatedAt time.Time `json:"created_at"` // CreatedAt 本次轮次记录创建时间。
}
type WorkflowPollRoundPage struct {
InstanceID string `json:"instance_id"` // InstanceID 所属实例。
Revision uint64 `json:"revision,string"` // Revision 本页读取时的实例版本。
ActivationID string `json:"activation_id"` // ActivationID 明确指定的历史激活。
Items []WorkflowPollRoundSummary `json:"items"` // Items 本激活内按业务轮倒序的摘要,空页为[]。
NextCursor string `json:"next_cursor,omitempty"` // NextCursor 绑定实例、激活及查询类型的后续位置。
}
// WorkflowSignalSummary 不聚合信号正文、关联键或证据;单条Get用于读取完整原始信号。
type WorkflowSignalSummary struct {
ID string `json:"id"` // ID 持久信号身份。
WorkflowID string `json:"workflow_id"` // WorkflowID 所属实例。
Revision uint64 `json:"revision,string"` // Revision 该信号当前记录版本。
Name string `json:"name"` // Name 原信号类型名称。
PayloadVersion string `json:"payload_version"` // PayloadVersion 固定内容契约;未核验旧数据可能为空。
TargetStepKey string `json:"target_step_key"` // TargetStepKey 原目标步骤名称,不跟随当前步骤变更。
TargetActivationSequence uint32 `json:"target_activation_sequence"` // TargetActivationSequence 原指定代次,未核验旧数据可能为0。
State string `json:"state"` // State 当前PENDING、CONSUMED、EXPIRED或REJECTED。
Disposition string `json:"disposition,omitempty"` // Disposition 已记录的消费或拒绝原因。
AcceptedAt time.Time `json:"accepted_at"` // AcceptedAt 原受理时间,也是分页排序依据。
ExpiresAt time.Time `json:"expires_at"` // ExpiresAt 原固定截止,不因读取或重报延长。
}
type WorkflowSignalPage struct {
WorkflowID string `json:"workflow_id"` // WorkflowID 已授权实例。
Revision uint64 `json:"revision,string"` // Revision 本页读取时的实例版本;跨页状态可变化。
Items []WorkflowSignalSummary `json:"items"` // Items 按受理时间及ID倒序的摘要,空页为[]。
NextCursor string `json:"next_cursor,omitempty"` // NextCursor 绑定命名空间及实例的后续位置。
}
type WorkflowSignalOperationSummary struct {
ID string `json:"id"` // ID 不可改写的操作审计身份。
WorkflowID string `json:"workflow_id"` // WorkflowID 经信号归属验证的实例。
SignalID string `json:"signal_id"` // SignalID 本次操作对应的信号资源。
OperationID string `json:"operation_id"` // OperationID 服务端操作身份,不是原始幂等键。
ActorID string `json:"actor_id"` // ActorID 已认证的实际操作主体。
Action string `json:"action"` // Action 操作种类,如workflow.signal。
CreatedAt time.Time `json:"created_at"` // CreatedAt 原操作创建时间。
}
type WorkflowSignalOperation struct {
WorkflowSignalOperationSummary
Reason string `json:"reason"` // Reason 原操作原因。
EvidenceRef string `json:"evidence_ref,omitempty"` // EvidenceRef 原证据引用,不保存外部秘密。
Before json.RawMessage `json:"before"` // Before 操作前摘要,首次受理可为null。
After json.RawMessage `json:"after"` // After 原受理身份、摘要、时间和当时状态,不替代当前消费状态。
}
type WorkflowSignalOperationPage struct {
Items []WorkflowSignalOperationSummary `json:"items"` // Items 按创建时间和ID倒序的有界审计摘要。
NextCursor string `json:"next_cursor,omitempty"` // NextCursor 绑定实例、信号及查询类型的后续位置。
}

View File

@@ -202,6 +202,13 @@ func TestIntegrationWorkflowInstancesSDK(t *testing.T) {
t.Fatal("start receipt", err)
}
id := started.Instance.ID
// 所有新增历史路由必须拒绝未声明参数,不能悄悄扩大读取范围。
for _, suffix := range []string{"/steps/work/activations", "/steps/work/activations/" + id, "/steps/work/activations/" + id + "/rounds", "/signals", "/signals/" + id + "/operations", "/signals/" + id + "/operations/" + id} {
var unused any
err := api.call(ctx, "GET", "/v1/workflows/"+id+suffix+"?unknown=1", "", nil, &unused)
workflowAPIError(t, err, "INVALID_ARGUMENT")
}
before, err := api.GetWorkflowSteps(ctx, id)
if err != nil || len(before.Steps) != 4 {
t.Fatal(err)
@@ -256,6 +263,27 @@ func TestIntegrationWorkflowInstancesSDK(t *testing.T) {
if err != nil || receipt.State != "CONSUMED" {
t.Fatal(err)
}
signals, err := api.ListWorkflowSignals(ctx, id, HistoryPageRequest{Limit: 1})
if err != nil || len(signals.Items) != 1 || signals.Items[0].ID != accepted.Signal.ID || signals.Items[0].State != "CONSUMED" {
t.Fatal("signal history", err)
}
operations, err := api.ListWorkflowSignalOperations(ctx, id, accepted.Signal.ID, HistoryPageRequest{Limit: 1})
if err != nil || len(operations.Items) != 1 {
t.Fatal("signal operations", err)
}
operation, err := api.GetWorkflowSignalOperation(ctx, id, accepted.Signal.ID, operations.Items[0].ID)
if err != nil || operation.Reason != signal.Reason || operation.Action != "workflow.signal" {
t.Fatal("signal operation detail", err)
}
historical, err := api.GetWorkflowStep(ctx, id, "receipt")
if err != nil {
t.Fatal(err)
}
waitHistory, err := api.GetWorkflowActivation(ctx, id, "receipt", *historical.Step.ActivationID)
if err != nil || waitHistory.Wait.WinnerSignalID == nil || *waitHistory.Wait.WinnerSignalID != accepted.Signal.ID {
t.Fatal("historical winner", err)
}
old, err := api.StartWorkflow(ctx, key, in)
if err != nil || old.Instance.Revision != started.Instance.Revision || old.Instance.DeadlineAt != started.Instance.DeadlineAt {
t.Fatal("start receipt changed", err)
@@ -344,6 +372,12 @@ func TestIntegrationWorkflowInstancesSDK(t *testing.T) {
if err != nil || current.Wait.DeadlineAt != old.Wait.DeadlineAt {
t.Fatal("wait extended", err)
}
priorWait, err := api.GetWorkflowActivation(ctx, v.ID, "receipt", *old.Step.ActivationID)
if err != nil || priorWait.Activation.Sequence != 1 || priorWait.Step.ActivationSequence != 2 || priorWait.Wait.DeadlineAt != old.Wait.DeadlineAt || priorWait.Wait.ID != old.Wait.ID {
t.Fatal("wait history retargeted", err)
}
assertTwoActivationPagesSDK(t, ctx, api, v.ID, "receipt", *old.Step.ActivationID, *restored.Step.ActivationID)
stale, err := api.SendWorkflowSignal(ctx, v.ID, workerTestID(t), SendWorkflowSignalRequest{Name: "done", PayloadVersion: "v1", TargetStepKey: "receipt", TargetActivationSequence: 1, CorrelationKey: "shared", Payload: workflowExact, Reason: "late prior generation"})
if err != nil || stale.Signal.State != "REJECTED" {
t.Fatal("old signal not fenced", err)
@@ -362,6 +396,12 @@ func TestIntegrationWorkflowInstancesSDK(t *testing.T) {
if err != nil || !repeated.Deduplicated || repeated.Task.ID != child.Task.ID {
t.Fatal(err)
}
assertTwoActivationPagesSDK(t, ctx, api, v.ID, "work", *step.Step.ActivationID, *child.Step.ActivationID)
oldTask, err := api.GetWorkflowActivation(ctx, v.ID, "work", *step.Step.ActivationID)
if err != nil || oldTask.Activation.TaskID == nil || *oldTask.Activation.TaskID != *child.Task.RetryOf || oldTask.Activation.State != "FAILED" {
t.Fatal("old task activation", err)
}
assertWorkflowSourceSDK(t, ctx, api, v, child.Task.ID, 2, 0, *child.Task.RetryOf)
assertWorkflowSourceSDK(t, ctx, api, v, *child.Task.RetryOf, 1, 0, "")
continueWorkflowSDK(t, ctx, api, v.ID)
@@ -414,6 +454,15 @@ func TestIntegrationWorkflowInstancesSDK(t *testing.T) {
if err != nil || final.Poll.UsedRounds != 2 || final.Poll.DeadlineAt != step.Poll.DeadlineAt {
t.Fatal("poll budget changed", err)
}
roundPage, err := api.ListWorkflowPollRounds(ctx, v.ID, "query", *final.Step.ActivationID, HistoryPageRequest{Limit: 1})
if err != nil || len(roundPage.Items) != 1 || roundPage.Items[0].Round != 2 || roundPage.Items[0].TaskID != last.Task.ID || roundPage.NextCursor == "" {
t.Fatal("round page", err)
}
priorRound, err := api.ListWorkflowPollRounds(ctx, v.ID, "query", *final.Step.ActivationID, HistoryPageRequest{Limit: 1, Cursor: roundPage.NextCursor})
if err != nil || len(priorRound.Items) != 1 || priorRound.Items[0].Round != 1 || priorRound.Items[0].TaskID != source.ID || priorRound.NextCursor != "" {
t.Fatal("prior round", err)
}
})
t.Run("poll_recovery_source_keeps_business_round", func(t *testing.T) {
spec := workflowSDKSpec(queue, handler)
@@ -434,9 +483,31 @@ func TestIntegrationWorkflowInstancesSDK(t *testing.T) {
continueWorkflowSDK(t, ctx, api, v.ID)
finishWorkflowSDK(t, ctx, w, WorkerSucceeded, json.RawMessage(`"DONE"`))
waitWorkflowSDK(t, ctx, api, v.ID, "SUCCEEDED")
assertTwoActivationPagesSDK(t, ctx, api, v.ID, "query", *step.Step.ActivationID, *child.Step.ActivationID)
for _, item := range []struct{ activation, task string }{{*step.Step.ActivationID, old.Task.ID}, {*child.Step.ActivationID, child.Task.ID}} {
rounds, err := api.ListWorkflowPollRounds(ctx, v.ID, "query", item.activation, HistoryPageRequest{})
if err != nil || len(rounds.Items) != 1 || rounds.Items[0].Round != 1 || rounds.Items[0].TaskID != item.task {
t.Fatal("recovery same business round", err)
}
}
assertWorkflowSourceSDK(t, ctx, api, v, child.Task.ID, 2, 1, old.Task.ID)
})
if !t.Failed() {
t.Log("public workflow start/query/control/signal, all six recovery commands, audit and real Worker execution verified without server imports")
}
}
func assertTwoActivationPagesSDK(t *testing.T, ctx context.Context, api *Client, id, key, old, current string) {
t.Helper()
first, err := api.ListWorkflowActivations(ctx, id, key, HistoryPageRequest{Limit: 1})
if err != nil || len(first.Items) != 1 || first.Items[0].ID != current || first.Items[0].Sequence != 2 || first.NextCursor == "" {
t.Fatal("current activation page", err)
}
second, err := api.ListWorkflowActivations(ctx, id, key, HistoryPageRequest{Limit: 1, Cursor: first.NextCursor})
if err != nil || len(second.Items) != 1 || second.Items[0].ID != old || second.Items[0].Sequence != 1 || second.NextCursor != "" {
t.Fatal("old activation page", err)
}
_, err = api.ListWorkflowSignals(ctx, id, HistoryPageRequest{Cursor: first.NextCursor})
workflowAPIError(t, err, "INVALID_ARGUMENT")
}