@temporal-architect/claude-plugin 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +38 -0
- package/package.json +37 -0
- package/skills/MANIFEST.json +373 -0
- package/skills/MANIFEST.md +121 -0
- package/skills/temporal-architect/SKILL.md +99 -0
- package/skills/temporal-architect/reference/decomposition.md +78 -0
- package/skills/temporal-architect-author-go/README.md +16 -0
- package/skills/temporal-architect-author-go/SKILL.md +191 -0
- package/skills/temporal-architect-author-go/SUBAGENT_ADOPTION.md +161 -0
- package/skills/temporal-architect-author-go/reference/activity-call.md +73 -0
- package/skills/temporal-architect-author-go/reference/activity-def.md +54 -0
- package/skills/temporal-architect-author-go/reference/assignment.md +36 -0
- package/skills/temporal-architect-author-go/reference/await-all.md +104 -0
- package/skills/temporal-architect-author-go/reference/await-one.md +193 -0
- package/skills/temporal-architect-author-go/reference/await-timer.md +35 -0
- package/skills/temporal-architect-author-go/reference/close.md +71 -0
- package/skills/temporal-architect-author-go/reference/composite-patterns.md +176 -0
- package/skills/temporal-architect-author-go/reference/condition.md +56 -0
- package/skills/temporal-architect-author-go/reference/control-flow.md +151 -0
- package/skills/temporal-architect-author-go/reference/dependency-resolution.md +29 -0
- package/skills/temporal-architect-author-go/reference/detach.md +52 -0
- package/skills/temporal-architect-author-go/reference/heartbeat.md +84 -0
- package/skills/temporal-architect-author-go/reference/nexus-service-def.md +73 -0
- package/skills/temporal-architect-author-go/reference/nexus.md +35 -0
- package/skills/temporal-architect-author-go/reference/options.md +138 -0
- package/skills/temporal-architect-author-go/reference/promise.md +73 -0
- package/skills/temporal-architect-author-go/reference/proto-driven.md +197 -0
- package/skills/temporal-architect-author-go/reference/query-handler.md +34 -0
- package/skills/temporal-architect-author-go/reference/signal-handler.md +73 -0
- package/skills/temporal-architect-author-go/reference/three-layer-testing.md +173 -0
- package/skills/temporal-architect-author-go/reference/types.md +72 -0
- package/skills/temporal-architect-author-go/reference/update-handler.md +64 -0
- package/skills/temporal-architect-author-go/reference/worker.md +215 -0
- package/skills/temporal-architect-author-go/reference/workflow-call.md +37 -0
- package/skills/temporal-architect-author-go/reference/workflow-def.md +45 -0
- package/skills/temporal-architect-author-infra/README.md +16 -0
- package/skills/temporal-architect-author-infra/SKILL.md +132 -0
- package/skills/temporal-architect-author-infra/reference/tcld.md +112 -0
- package/skills/temporal-architect-author-infra/reference/terraform.md +125 -0
- package/skills/temporal-architect-design/README.md +16 -0
- package/skills/temporal-architect-design/SKILL.md +224 -0
- package/skills/temporal-architect-design/reference/LANGUAGE.md +5 -0
- package/skills/temporal-architect-design/reference/anti-patterns.md +332 -0
- package/skills/temporal-architect-design/reference/common-errors.md +88 -0
- package/skills/temporal-architect-design/reference/core-principles.md +52 -0
- package/skills/temporal-architect-design/reference/design-checklist.md +59 -0
- package/skills/temporal-architect-design/reference/namespaces.md +84 -0
- package/skills/temporal-architect-design/reference/notation-examples.md +304 -0
- package/skills/temporal-architect-design/reference/notation-reference.md +70 -0
- package/skills/temporal-architect-design/reference/primitives-reference.md +65 -0
- package/skills/temporal-architect-design/reference/project-discovery-subagent.md +80 -0
- package/skills/temporal-architect-design/reference/reverse-engineering.md +53 -0
- package/skills/temporal-architect-design/reference/twf-conventions.md +43 -0
- package/skills/temporal-architect-design/reference/workflow-boundaries.md +43 -0
- package/skills/temporal-architect-design/topics/activities-advanced.md +358 -0
- package/skills/temporal-architect-design/topics/activities-advanced.twf +107 -0
- package/skills/temporal-architect-design/topics/child-workflows.md +347 -0
- package/skills/temporal-architect-design/topics/child-workflows.twf +171 -0
- package/skills/temporal-architect-design/topics/long-running.md +230 -0
- package/skills/temporal-architect-design/topics/long-running.twf +100 -0
- package/skills/temporal-architect-design/topics/nexus.md +248 -0
- package/skills/temporal-architect-design/topics/nexus.twf +148 -0
- package/skills/temporal-architect-design/topics/patterns.md +469 -0
- package/skills/temporal-architect-design/topics/patterns.twf +346 -0
- package/skills/temporal-architect-design/topics/promises-conditions.md +179 -0
- package/skills/temporal-architect-design/topics/promises-conditions.twf +213 -0
- package/skills/temporal-architect-design/topics/signals-queries-updates.md +319 -0
- package/skills/temporal-architect-design/topics/signals-queries-updates.twf +234 -0
- package/skills/temporal-architect-design/topics/task-queues.md +205 -0
- package/skills/temporal-architect-design/topics/task-queues.twf +184 -0
- package/skills/temporal-architect-design/topics/testing.md +437 -0
- package/skills/temporal-architect-design/topics/testing.twf +177 -0
- package/skills/temporal-architect-design/topics/timers-scheduling.md +131 -0
- package/skills/temporal-architect-design/topics/timers-scheduling.twf +129 -0
- package/skills/temporal-architect-design/topics/versioning.md +434 -0
- package/skills/temporal-architect-design/topics/versioning.twf +174 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# promise
|
|
2
|
+
|
|
3
|
+
## DSL
|
|
4
|
+
|
|
5
|
+
```twf
|
|
6
|
+
promise handleA <- activity ProcessA(items.a)
|
|
7
|
+
# ... do other work ...
|
|
8
|
+
await handleA -> resultA
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Go
|
|
12
|
+
|
|
13
|
+
```go
|
|
14
|
+
futureA := workflow.ExecuteActivity(ctx, ProcessA, items.A)
|
|
15
|
+
// ... do other work ...
|
|
16
|
+
var resultA ResultA
|
|
17
|
+
err := futureA.Get(ctx, &resultA)
|
|
18
|
+
if err != nil {
|
|
19
|
+
return Result{}, err
|
|
20
|
+
}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Variants
|
|
24
|
+
|
|
25
|
+
```twf
|
|
26
|
+
promise childHandle <- workflow SlowChild(input.data)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```go
|
|
30
|
+
childFuture := workflow.ExecuteChildWorkflow(ctx, SlowChild, input.Data)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```twf
|
|
34
|
+
promise timeout <- timer(5m)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
```go
|
|
38
|
+
timerFuture := workflow.NewTimer(ctx, 5*time.Minute)
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
```twf
|
|
42
|
+
promise approved <- signal Approved
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
```go
|
|
46
|
+
approvedCh := workflow.GetSignalChannel(ctx, "Approved")
|
|
47
|
+
// Use approvedCh.Receive later, or add to selector
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
```twf
|
|
51
|
+
promise payHandle <- nexus BillingEndpoint BillingService.ChargePayment(payment)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
```go
|
|
55
|
+
c := workflow.NewNexusClient("BillingEndpoint", "BillingService")
|
|
56
|
+
payFuture := c.ExecuteOperation(ctx, "ChargePayment", payment, workflow.NexusOperationOptions{})
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Notes
|
|
60
|
+
|
|
61
|
+
- A promise is just a future — the call starts immediately, `.Get` defers the blocking
|
|
62
|
+
- Activity/workflow promises → `workflow.Future` from `ExecuteActivity`/`ExecuteChildWorkflow`
|
|
63
|
+
- Timer promises → `workflow.Future` from `workflow.NewTimer`
|
|
64
|
+
- Signal promises → `workflow.ReceiveChannel` from `workflow.GetSignalChannel` — **not a `Future`**. Use `.Receive()` to block or add to a selector with `AddReceive`. Do not call `.Get()` on a channel
|
|
65
|
+
- Nexus promises → `NexusOperationFuture` from `NexusClient.ExecuteOperation`; same `.Get()` pattern as activity/workflow futures. Also has `GetNexusOperationExecution()` to optionally wait for the operation to start (not finish)
|
|
66
|
+
- Updates are handler-driven, not future-driven — they don't produce futures directly. To race an update completion, use a channel set by the update handler (see [update-handler.md](./update-handler.md))
|
|
67
|
+
- Promises used in `await one:` are added as selector cases — see [await-one.md](./await-one.md)
|
|
68
|
+
|
|
69
|
+
## When to use
|
|
70
|
+
|
|
71
|
+
- Use a promise (deferred `.Get`) when other work can proceed before the result is needed — the call starts immediately and runs concurrently with subsequent workflow code
|
|
72
|
+
- Use an inline blocking call (`.Get()` immediately) when the result is needed before any further work. This is the simpler pattern and should be the default
|
|
73
|
+
- Promises are essential for `await all:` (parallel fan-out) and `await one:` (racing) patterns
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# proto-driven
|
|
2
|
+
|
|
3
|
+
Codegen variant where **protobuf is the single source of truth** for workflow and activity interfaces. Generators produce the Temporal framework — typed interfaces, registration helpers, activity futures, Nexus stubs — leaving only business logic hand-written. Route here when the [Orient](../SKILL.md#orient) signals fire (`buf.gen.yaml` → `protoc-gen-go_temporal`, generated `*_temporal.pb.go`, `(temporal.v1.activity|workflow)` annotations).
|
|
4
|
+
|
|
5
|
+
Sometimes called **PFI** (proto-first interfaces). Detect it from those repo signals, never from the name.
|
|
6
|
+
|
|
7
|
+
In an existing proto-first repo, the layout, tooling, and naming are **requirements to match** — conform to what discovery found; do not impose this skeleton over a different one.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Contract + layout
|
|
12
|
+
|
|
13
|
+
Three directories, one direction of flow: `proto/` (source of truth) → `gen/` (generated, never edit) → `lib/` (hand-written).
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
<project>/
|
|
17
|
+
├── proto/<service>/<version>/<service>.proto # interface definitions (source of truth)
|
|
18
|
+
├── gen/<service>/<version>/
|
|
19
|
+
│ ├── *.pb.go # generated: message types
|
|
20
|
+
│ └── *_temporal.pb.go # generated: interfaces, registration helpers, futures
|
|
21
|
+
└── lib/<service>/<version>/
|
|
22
|
+
├── activities.go # hand-written: implements generated Activities interface
|
|
23
|
+
├── workflows.go # hand-written: implements generated Workflows interface (if any)
|
|
24
|
+
├── client.go # hand-written: external system client
|
|
25
|
+
└── fx.go # hand-written: dependency injection wiring
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Everything under `gen/` is generated — never hand-edit it.
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Tools
|
|
33
|
+
|
|
34
|
+
[buf](https://buf.build) drives generation; plugins are installed from `go.mod` so versions are pinned to the module:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
go install google.golang.org/protobuf/cmd/protoc-gen-go
|
|
38
|
+
go install github.com/alta/protopatch/cmd/protoc-gen-go-patch
|
|
39
|
+
go install github.com/cludden/protoc-gen-go-temporal/cmd/protoc-gen-go_temporal
|
|
40
|
+
# Optional: Nexus support
|
|
41
|
+
go install github.com/bergundy/protoc-gen-go-nexus/cmd/protoc-gen-go-nexus
|
|
42
|
+
go install github.com/bergundy/protoc-gen-go-nexus-temporal/cmd/protoc-gen-go-nexus-temporal
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
These must be on `$PATH` when `buf generate` runs.
|
|
46
|
+
|
|
47
|
+
### `buf.yaml` skeleton
|
|
48
|
+
|
|
49
|
+
```yaml
|
|
50
|
+
version: v2
|
|
51
|
+
modules:
|
|
52
|
+
- path: proto/<service>
|
|
53
|
+
deps:
|
|
54
|
+
- buf.build/cludden/protoc-gen-go-temporal # Temporal annotations
|
|
55
|
+
- buf.build/alta/protopatch # struct tag patching
|
|
56
|
+
lint:
|
|
57
|
+
use:
|
|
58
|
+
- STANDARD
|
|
59
|
+
except:
|
|
60
|
+
# Temporal uses XxxInput/XxxOutput, not XxxRequest/XxxResponse
|
|
61
|
+
- RPC_REQUEST_STANDARD_NAME
|
|
62
|
+
- RPC_RESPONSE_STANDARD_NAME
|
|
63
|
+
- RPC_REQUEST_RESPONSE_UNIQUE
|
|
64
|
+
breaking:
|
|
65
|
+
use:
|
|
66
|
+
- FILE
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### `buf.gen.yaml` skeleton
|
|
70
|
+
|
|
71
|
+
```yaml
|
|
72
|
+
version: v2
|
|
73
|
+
managed:
|
|
74
|
+
enabled: true
|
|
75
|
+
plugins:
|
|
76
|
+
- local: protoc-gen-go-patch # Go message types (with struct tag patching)
|
|
77
|
+
out: gen/<service>
|
|
78
|
+
opt: [paths=source_relative, plugin=go]
|
|
79
|
+
- local: protoc-gen-go_temporal # Temporal workflow/activity stubs and interfaces
|
|
80
|
+
out: gen/<service>
|
|
81
|
+
opt: [paths=source_relative, enable-patch-support=true]
|
|
82
|
+
strategy: all
|
|
83
|
+
inputs:
|
|
84
|
+
- directory: proto/<service>
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Run generation with `buf generate`.
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## Proto annotations
|
|
92
|
+
|
|
93
|
+
```proto
|
|
94
|
+
service MyService {
|
|
95
|
+
option (temporal.v1.service) = {task_queue: "my-task-queue"};
|
|
96
|
+
|
|
97
|
+
rpc CreateThing(CreateThingInput) returns (CreateThingOutput) {
|
|
98
|
+
option (temporal.v1.activity) = {
|
|
99
|
+
name: "myservice.v1.CreateThing"
|
|
100
|
+
schedule_to_close_timeout: {seconds: 300}
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
rpc DeployCluster(DeployClusterInput) returns (DeployClusterOutput) {
|
|
105
|
+
option (temporal.v1.workflow) = {
|
|
106
|
+
name: "myservice.v1.DeployCluster"
|
|
107
|
+
id: "deploy-cluster-{{.Input.ClusterName}}"
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
message CreateThingInput {
|
|
113
|
+
string name = 1 [(go.field).tags = 'json:"Name" validate:"required"'];
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
- `(temporal.v1.service)` sets the task queue for all RPCs in the service.
|
|
118
|
+
- `(temporal.v1.activity)` / `(temporal.v1.workflow)` mark an RPC; set a unique `name` and timeouts. Workflows and activities coexist in one service.
|
|
119
|
+
- `(go.field).tags` injects struct tags on the generated Go type (requires `protopatch`).
|
|
120
|
+
- Use `XxxInput` / `XxxOutput` naming — the Temporal ecosystem expects these, not `Request`/`Response`.
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## Generated-symbol table
|
|
125
|
+
|
|
126
|
+
For each annotated service, `protoc-gen-go_temporal` emits these in `*_temporal.pb.go`. This is the Rosetta Stone — the same table is read **backward** during [reverse engineering](../../temporal-architect-design/reference/reverse-engineering.md) to recover intent from generated code.
|
|
127
|
+
|
|
128
|
+
| Generated symbol | Purpose |
|
|
129
|
+
|---|---|
|
|
130
|
+
| `XxxActivities` interface | implement with your business logic |
|
|
131
|
+
| `RegisterXxxActivities(worker, impl)` | register all activities at once |
|
|
132
|
+
| `XxxActivityName` constant | activity name string for Temporal |
|
|
133
|
+
| `XxxFuture` struct | typed future for async activity calls from workflows |
|
|
134
|
+
| `XxxWorkflows` interface | implement for workflows (if RPCs are annotated as workflows) |
|
|
135
|
+
| `RegisterXxxWorkflows(worker, impl)` | register all workflows |
|
|
136
|
+
| `XxxClient` struct | call activities/workflows from outside Temporal |
|
|
137
|
+
|
|
138
|
+
You never write these — only the implementations.
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## Implement, register, client
|
|
143
|
+
|
|
144
|
+
**Implement** the generated interface; each method is validate → call → return:
|
|
145
|
+
|
|
146
|
+
```go
|
|
147
|
+
// activities.go
|
|
148
|
+
type Activities struct {
|
|
149
|
+
client MyServiceClient // hand-written external system client
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
func (a *Activities) CreateThing(ctx context.Context, req *pb.CreateThingInput) (*pb.CreateThingOutput, error) {
|
|
153
|
+
if req.Name == "" { // 1. validate
|
|
154
|
+
return nil, errors.New("name is required")
|
|
155
|
+
}
|
|
156
|
+
id, err := a.client.Create(ctx, req.Name) // 2. call external system
|
|
157
|
+
if err != nil {
|
|
158
|
+
return nil, fmt.Errorf("create thing: %w", err)
|
|
159
|
+
}
|
|
160
|
+
return &pb.CreateThingOutput{Id: id}, nil // 3. return typed output
|
|
161
|
+
}
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
**Register** the generated helper at worker startup (commonly via `fx`):
|
|
165
|
+
|
|
166
|
+
```go
|
|
167
|
+
// fx.go
|
|
168
|
+
var Module = fx.Options(
|
|
169
|
+
fx.Provide(NewMyServiceClient),
|
|
170
|
+
fx.Provide(func(c MyServiceClient) pb.MyServiceActivities { return NewActivities(c) }),
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
func RegisterActivities(w worker.Worker, a pb.MyServiceActivities) {
|
|
174
|
+
pb.RegisterMyServiceActivities(w, a) // generated; missing registration → "activity not found" at runtime
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
**Client interface** — define a hand-written interface for the external system in the implementation package so activities depend on the interface, not the concrete type (and it can be mocked in tests):
|
|
179
|
+
|
|
180
|
+
```go
|
|
181
|
+
// client.go
|
|
182
|
+
type MyServiceClient interface {
|
|
183
|
+
Create(ctx context.Context, name string) (id string, err error)
|
|
184
|
+
Delete(ctx context.Context, id string) error
|
|
185
|
+
}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
For the testing seam on these generated and hand-written interfaces, see [three-layer-testing.md](./three-layer-testing.md).
|
|
189
|
+
|
|
190
|
+
---
|
|
191
|
+
|
|
192
|
+
## References
|
|
193
|
+
|
|
194
|
+
- [protoc-gen-go-temporal](https://github.com/cludden/protoc-gen-go-temporal) — the core Temporal code generator
|
|
195
|
+
- [protopatch](https://github.com/alta/protopatch) — struct tag injection for Go proto types
|
|
196
|
+
- [buf](https://buf.build/docs) — proto dependency management and generation pipeline
|
|
197
|
+
- [three-layer-testing.md](./three-layer-testing.md) — testing strategy; the generated activities interface is the proto seam for mocks
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# query handler
|
|
2
|
+
|
|
3
|
+
## DSL
|
|
4
|
+
|
|
5
|
+
```twf
|
|
6
|
+
query GetStatus() -> (string):
|
|
7
|
+
return status
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
## Go
|
|
11
|
+
|
|
12
|
+
```go
|
|
13
|
+
err := workflow.SetQueryHandler(ctx, "GetStatus", func() (string, error) {
|
|
14
|
+
return status, nil
|
|
15
|
+
})
|
|
16
|
+
if err != nil {
|
|
17
|
+
return Result{}, err
|
|
18
|
+
}
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Notes
|
|
22
|
+
|
|
23
|
+
- Query handlers always return `(ReturnType, error)`
|
|
24
|
+
- Query handlers must not modify workflow state — read-only
|
|
25
|
+
- Query handlers have no `workflow.Context` parameter in their signature
|
|
26
|
+
- With params: `func(param1 Type1, param2 Type2) (ReturnType, error)`
|
|
27
|
+
- Non-deterministic operations (e.g., ranging over a map) are permitted in query handlers — they execute outside the replay path
|
|
28
|
+
- Register queries at the very start of the workflow function, before any blocking calls. If a query arrives before the handler is registered during replay, the caller receives an `unknown queryType` error
|
|
29
|
+
|
|
30
|
+
## Pitfalls
|
|
31
|
+
|
|
32
|
+
- **No blocking operations.** Query handlers must not call `workflow.Go()`, `workflow.NewChannel()`, `Channel.Get()`, `Future.Get()`, or `workflow.Await()`. Violation causes a panic caught by the SDK, returning `QueryFailedError` to the caller
|
|
33
|
+
- **No activities or commands.** Query handlers cannot execute activities, schedule timers, start child workflows, or produce any workflow command. This also produces `QueryFailedError`
|
|
34
|
+
- These restrictions exist because queries are dispatched outside the normal workflow execution context — they cannot participate in the event history
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# signal handler
|
|
2
|
+
|
|
3
|
+
## DSL
|
|
4
|
+
|
|
5
|
+
```twf
|
|
6
|
+
workflow OrderWorkflow(orderId: string) -> (OrderResult):
|
|
7
|
+
signal PaymentReceived(transactionId: string, amount: decimal):
|
|
8
|
+
status = "payment_received"
|
|
9
|
+
lastTransactionId = transactionId
|
|
10
|
+
|
|
11
|
+
# ... body uses await signal PaymentReceived or await one with signal case
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Go
|
|
15
|
+
|
|
16
|
+
```go
|
|
17
|
+
func OrderWorkflow(ctx workflow.Context, orderId string) (OrderResult, error) {
|
|
18
|
+
var status string
|
|
19
|
+
var lastTransactionId string
|
|
20
|
+
|
|
21
|
+
// Signal struct
|
|
22
|
+
type PaymentReceivedSignal struct {
|
|
23
|
+
TransactionId string
|
|
24
|
+
Amount float64
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Signal channel
|
|
28
|
+
paymentReceivedCh := workflow.GetSignalChannel(ctx, "PaymentReceived")
|
|
29
|
+
|
|
30
|
+
// Register handler via goroutine that loops on the channel
|
|
31
|
+
workflow.Go(ctx, func(gCtx workflow.Context) {
|
|
32
|
+
for {
|
|
33
|
+
var sig PaymentReceivedSignal
|
|
34
|
+
paymentReceivedCh.Receive(gCtx, &sig)
|
|
35
|
+
status = "payment_received"
|
|
36
|
+
lastTransactionId = sig.TransactionId
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
// ... workflow body
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Notes
|
|
45
|
+
|
|
46
|
+
- Signal params become a struct; signal name becomes the channel name string
|
|
47
|
+
- The handler goroutine loops forever — it processes every signal arrival, not just the first
|
|
48
|
+
- Handler body mutates workflow-scoped variables (closures over workflow state)
|
|
49
|
+
- Signals with no params: use `paymentReceivedCh.Receive(gCtx, nil)`
|
|
50
|
+
- When a signal is also used in `await one:`, the selector reads from the same channel — see [await-one.md](./await-one.md)
|
|
51
|
+
- `decimal` type in DSL maps to `float64` by default. For financial values where precision matters, consider `shopspring/decimal` or integer-cents representation instead
|
|
52
|
+
|
|
53
|
+
## When to use each pattern
|
|
54
|
+
|
|
55
|
+
- **Goroutine loop** (`workflow.Go` + `Receive` in a loop, as shown above): use when every signal must be processed as it arrives, independent of the main workflow flow. Most common pattern
|
|
56
|
+
- **Blocking `Receive` inline**: use when the workflow must pause and wait for exactly one signal before continuing. Simpler but blocks the main flow
|
|
57
|
+
- **`Selector.AddReceive`**: use when racing a signal against other events (timers, activities, other signals) — see [await-one.md](./await-one.md)
|
|
58
|
+
|
|
59
|
+
## Pitfalls
|
|
60
|
+
|
|
61
|
+
- **Signal drain before Continue-As-New (Go SDK only).** Signals are lost if the channel is not drained before `workflow.NewContinueAsNewError`. Drain with `ReceiveAsync` immediately before CAN:
|
|
62
|
+
```go
|
|
63
|
+
for {
|
|
64
|
+
var sig SignalType
|
|
65
|
+
if !signalCh.ReceiveAsync(&sig) {
|
|
66
|
+
break
|
|
67
|
+
}
|
|
68
|
+
// process or forward to next run via workflow input
|
|
69
|
+
}
|
|
70
|
+
return workflow.NewContinueAsNewError(ctx, MyWorkflow, state)
|
|
71
|
+
```
|
|
72
|
+
- When using a `Selector` for signal handling, drain with `selector.HasPending()` + `selector.Select(ctx)` loop before CAN
|
|
73
|
+
- Do not trigger Continue-As-New from inside a signal handler — call it from the main workflow thread to avoid signal loss
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# three-layer-testing
|
|
2
|
+
|
|
3
|
+
Temporal Go code divides into three testable layers. **Each layer mocks only its direct dependency** — never anything deeper. This is good practice for any clean Go Temporal project, not just proto-driven ones.
|
|
4
|
+
|
|
5
|
+
| Layer | File | Mocks | Build tag | Speed |
|
|
6
|
+
|---|---|---|---|---|
|
|
7
|
+
| Workflows | `workflows_test.go` | activities interface | *(none)* | fast, no Docker |
|
|
8
|
+
| Activities | `activities_test.go` | client interface | *(none)* | fast, no Docker |
|
|
9
|
+
| Clients | `client_test.go` | nothing (real system) | `integration` | slow, needs Docker |
|
|
10
|
+
|
|
11
|
+
**Why three layers:** workflow tests check orchestration (right activities, right order, failures propagate) but can't see bugs inside activities; activity tests check validation and error handling but can't see bugs in the real client; client tests check the real protocol/error codes that mocks would hide. Each layer catches bugs the others cannot.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Layer 1: Workflow tests
|
|
16
|
+
|
|
17
|
+
Use `testsuite.WorkflowTestSuite`; mock the activities interface so the workflow can be driven to any outcome without real systems. A fresh environment per table case:
|
|
18
|
+
|
|
19
|
+
```go
|
|
20
|
+
func TestProcessOrderWorkflow(t *testing.T) {
|
|
21
|
+
tests := []struct {
|
|
22
|
+
name string
|
|
23
|
+
createErr error
|
|
24
|
+
wantErr bool
|
|
25
|
+
}{
|
|
26
|
+
{name: "success - order processed", wantErr: false},
|
|
27
|
+
{name: "error - charge fails", createErr: errors.New("declined"), wantErr: true},
|
|
28
|
+
}
|
|
29
|
+
for _, tt := range tests {
|
|
30
|
+
t.Run(tt.name, func(t *testing.T) {
|
|
31
|
+
env := (&testsuite.WorkflowTestSuite{}).NewTestWorkflowEnvironment()
|
|
32
|
+
mockActivities := mocks.NewMockActivities(t)
|
|
33
|
+
env.RegisterActivity(mockActivities)
|
|
34
|
+
|
|
35
|
+
// Conditional mock: only set expectations on the path the case reaches.
|
|
36
|
+
mockActivities.EXPECT().
|
|
37
|
+
ChargePayment(mock.Anything, mock.Anything).
|
|
38
|
+
Return(&pb.ChargeOutput{Id: "ch-1"}, tt.createErr)
|
|
39
|
+
|
|
40
|
+
env.ExecuteWorkflow(ProcessOrder, &pb.OrderInput{})
|
|
41
|
+
|
|
42
|
+
err := env.GetWorkflowError()
|
|
43
|
+
if tt.wantErr {
|
|
44
|
+
require.Error(t, err)
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
require.NoError(t, err)
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
**Split validation tests from execution tests.** A constructor that validates input is tested directly, with no workflow environment — those run in milliseconds:
|
|
54
|
+
|
|
55
|
+
```go
|
|
56
|
+
func TestNewProcessOrder(t *testing.T) {
|
|
57
|
+
_, err := newProcessOrder(&pb.OrderInput{}) // empty → invalid
|
|
58
|
+
require.ErrorContains(t, err, "order_id")
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Workflow tests also guard **replay safety**: Temporal replays from event history, so if changed code executes activities in a different order, replay breaks. These tests pin the activity-call sequence for given inputs.
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## Layer 2: Activity tests
|
|
67
|
+
|
|
68
|
+
Mock the client interface. Cover, in order: validation errors (caught before any client call), success, then each client error type.
|
|
69
|
+
|
|
70
|
+
```go
|
|
71
|
+
func TestCreateThingActivity(t *testing.T) {
|
|
72
|
+
tests := []struct {
|
|
73
|
+
name string
|
|
74
|
+
input *pb.CreateThingInput
|
|
75
|
+
clientErr error
|
|
76
|
+
wantErr bool
|
|
77
|
+
}{
|
|
78
|
+
{name: "error - empty name", input: &pb.CreateThingInput{}, wantErr: true}, // validation
|
|
79
|
+
{name: "success", input: &pb.CreateThingInput{Name: "x"}, wantErr: false}, // success
|
|
80
|
+
{name: "error - already exists", input: &pb.CreateThingInput{Name: "x"}, clientErr: errExists, wantErr: true}, // client error
|
|
81
|
+
}
|
|
82
|
+
for _, tt := range tests {
|
|
83
|
+
t.Run(tt.name, func(t *testing.T) {
|
|
84
|
+
env := (&testsuite.WorkflowTestSuite{}).NewTestActivityEnvironment()
|
|
85
|
+
mockClient := mocks.NewMockClient(t)
|
|
86
|
+
|
|
87
|
+
// Conditional mock: only expect a client call if validation would pass.
|
|
88
|
+
if tt.input.Name != "" {
|
|
89
|
+
mockClient.EXPECT().Create(mock.Anything, tt.input.Name).Return("id-123", tt.clientErr)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
acts := NewActivities(mockClient)
|
|
93
|
+
env.RegisterActivity(acts.CreateThing)
|
|
94
|
+
_, err := env.ExecuteActivity(acts.CreateThing, tt.input)
|
|
95
|
+
|
|
96
|
+
if tt.wantErr {
|
|
97
|
+
require.Error(t, err)
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
require.NoError(t, err)
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
**Conditional mocks** are the key pattern: when input is invalid, set no expectations — mockery fails the test if the client is called anyway, which proves the validation layer works.
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## Layer 3: Client tests (integration)
|
|
111
|
+
|
|
112
|
+
Real system in a container via [testcontainers-go](https://golang.testcontainers.org), gated by a build tag so normal `go test ./...` skips them:
|
|
113
|
+
|
|
114
|
+
```go
|
|
115
|
+
//go:build integration
|
|
116
|
+
|
|
117
|
+
func TestMyServiceClient(t *testing.T) {
|
|
118
|
+
if testing.Short() {
|
|
119
|
+
t.Skip("skipping integration test in short mode")
|
|
120
|
+
}
|
|
121
|
+
ctx := context.Background()
|
|
122
|
+
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
|
|
123
|
+
ContainerRequest: testcontainers.ContainerRequest{
|
|
124
|
+
Image: "myservice:latest",
|
|
125
|
+
ExposedPorts: []string{"8080/tcp"},
|
|
126
|
+
WaitingFor: wait.ForHTTP("/health").WithPort("8080/tcp"),
|
|
127
|
+
},
|
|
128
|
+
Started: true,
|
|
129
|
+
})
|
|
130
|
+
require.NoError(t, err)
|
|
131
|
+
defer container.Terminate(ctx)
|
|
132
|
+
|
|
133
|
+
host, _ := container.Host(ctx)
|
|
134
|
+
port, _ := container.MappedPort(ctx, "8080")
|
|
135
|
+
client := NewMyServiceClient(fmt.Sprintf("http://%s:%s", host, port.Port()))
|
|
136
|
+
|
|
137
|
+
id, err := client.Create(ctx, "my-thing")
|
|
138
|
+
require.NoError(t, err)
|
|
139
|
+
require.NotEmpty(t, id)
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
- The `//go:build integration` tag keeps these out of normal runs; include with `go test -tags=integration ./...`.
|
|
144
|
+
- Start a fresh container per test function; always `defer container.Terminate(ctx)`.
|
|
145
|
+
- Assert against the client's **real error types** (`ErrNotFound`), not test sentinels — this verifies the client translates system error codes correctly.
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
## Mock generation (the proto seam)
|
|
150
|
+
|
|
151
|
+
Hand-written interfaces can be mocked by hand or with a generator. When the project is **proto-driven**, the activities interface is generated (`XxxActivities` — see [proto-driven.md](./proto-driven.md)), so its mock must be generated too. [mockery](https://vektra.github.io/mockery/) generates both kinds from a `.mockery.yaml`:
|
|
152
|
+
|
|
153
|
+
```yaml
|
|
154
|
+
with-expecter: true
|
|
155
|
+
packages:
|
|
156
|
+
github.com/myorg/myproject/gen/myservice/v1: # generated activities interface → workflow tests
|
|
157
|
+
interfaces:
|
|
158
|
+
MyServiceActivities:
|
|
159
|
+
github.com/myorg/myproject/lib/myservice/v1: # hand-written client interface → activity tests
|
|
160
|
+
interfaces:
|
|
161
|
+
MyServiceClient:
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Generated mocks are an **option within** testing — the one proto seam — not a requirement of the three-layer approach.
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
## Key principles
|
|
169
|
+
|
|
170
|
+
- **Only mock your direct dependency.** Workflow → activities; activity → client. Never reach two layers deep (a workflow must not know about HTTP).
|
|
171
|
+
- **Conditional mocks mirror code flow.** No expectation set for inputs that validation rejects.
|
|
172
|
+
- **Fresh setup per case.** Never share `env` or mock state across table-driven cases.
|
|
173
|
+
- **Test names are documentation.** `"error - empty role name"`, not `"test1"`.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# types
|
|
2
|
+
|
|
3
|
+
## Strategy
|
|
4
|
+
|
|
5
|
+
Types in generated code come from two sources. Each has a different resolution method.
|
|
6
|
+
|
|
7
|
+
**Types you define** — derived from the `.twf` file. You control the shape; the TWF tells you what fields exist.
|
|
8
|
+
|
|
9
|
+
**Dependency types** — from external packages. The shape is fixed; you discover it at the call site.
|
|
10
|
+
|
|
11
|
+
### Defined types
|
|
12
|
+
|
|
13
|
+
Resolve in priority order:
|
|
14
|
+
|
|
15
|
+
1. **Explicit signatures** — workflow/activity params and return types name the type directly
|
|
16
|
+
2. **Constructor usage** — `Result{field: value}` reveals struct fields and their types (inferred from the values assigned)
|
|
17
|
+
3. **Field access** — `order.items` implies `Order` has an `items` field; the accessed type propagates from usage context
|
|
18
|
+
4. **Generate** — only for application-specific types with no existing match
|
|
19
|
+
|
|
20
|
+
### Dependency types
|
|
21
|
+
|
|
22
|
+
The ground truth is the call site — the method you will call in the activity body.
|
|
23
|
+
|
|
24
|
+
1. **Identify the method** — which function or method on the dependency will the activity call?
|
|
25
|
+
2. **Read the method signature** — `go doc <package>.Method` gives you the exact parameter and return types. If `go doc` is unavailable (dependency not yet in `go.mod`, docs sparse, or offline), fall back to: read source on pkg.go.dev, inspect the dependency source directly, or ask the user
|
|
26
|
+
3. **Read each parameter type** — `go doc <package>.ParamType` gives you the fields you need to populate. Verify every field type the same way — the field name alone can be misleading (e.g., a `Tools` field may accept a union wrapper type, not the type you'd guess from the name)
|
|
27
|
+
4. **Follow the chain until you reach primitives or types you recognize** — stop when every type in the call is verified
|
|
28
|
+
|
|
29
|
+
Dependency APIs are version-specific — verify types against the version in `go.mod`, not trained knowledge. A dependency is resolved when you can write the full call expression with concrete types:
|
|
30
|
+
```
|
|
31
|
+
client.Messages.New(ctx, anthropic.MessageNewParams{
|
|
32
|
+
Model: anthropic.Model(model), // verified: Model is a string typedef
|
|
33
|
+
Messages: []anthropic.MessageParam{}, // verified: not []Message
|
|
34
|
+
Tools: []anthropic.ToolUnionParam{}, // verified: not []ToolParam
|
|
35
|
+
})
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Serialization boundaries
|
|
39
|
+
|
|
40
|
+
Workflow and activity parameters pass through Temporal's data converter (JSON by default). Types you define are safe — you control their fields and they round-trip cleanly. Dependency types may have custom marshaling, unexported fields, or non-JSON-safe constructs.
|
|
41
|
+
|
|
42
|
+
**Keep dependency types inside activity bodies.** Expose your own types in workflow/activity signatures and convert to dependency types within the activity implementation. This keeps the serialization boundary clean and decouples your workflow logic from any single dependency.
|
|
43
|
+
|
|
44
|
+
## Go
|
|
45
|
+
|
|
46
|
+
```go
|
|
47
|
+
// From explicit signature: activity ValidateOrder(order: Order) -> (ValidateResult)
|
|
48
|
+
type Order struct { /* fields derived from usage */ }
|
|
49
|
+
type ValidateResult struct { /* fields derived from constructor */ }
|
|
50
|
+
|
|
51
|
+
// From constructor: Result{status: "completed", trackingId: reservation.trackingId}
|
|
52
|
+
type Result struct {
|
|
53
|
+
Status string
|
|
54
|
+
TrackingId string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Primitive mapping
|
|
58
|
+
// string → string
|
|
59
|
+
// int → int
|
|
60
|
+
// decimal → float64
|
|
61
|
+
// bool → bool
|
|
62
|
+
// []Type → []Type
|
|
63
|
+
// duration → time.Duration (e.g., 5m → 5*time.Minute)
|
|
64
|
+
// time → time.Time
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Notes
|
|
68
|
+
|
|
69
|
+
- Collect all constructor sites and field accesses across the `.twf` file before defining a type — a type used in multiple places may reveal different fields in each
|
|
70
|
+
- When a field's type is ambiguous (e.g., assigned from an untyped expression), leave a `// TODO` comment and ask the user
|
|
71
|
+
- Generic containers: `Map[K]V` → `map[K]V`, `[]Item` → `[]Item`
|
|
72
|
+
- Export all struct fields (uppercase) — these cross workflow/activity boundaries via serialization
|