opencode-effect-enforcer 0.2.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.
Files changed (118) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +278 -0
  3. package/guidance/effect-first-development.md +1247 -0
  4. package/guidance/post__effect-and-the-near-inexpressible-majesty-of-layers.md +490 -0
  5. package/guidance/post__parse-dont-validate.md +109 -0
  6. package/guidance/progressive-disclosure-guidance.md +38 -0
  7. package/package.json +63 -0
  8. package/patterns/avoid-any.md +37 -0
  9. package/patterns/avoid-data-tagged-error.md +34 -0
  10. package/patterns/avoid-direct-json.md +51 -0
  11. package/patterns/avoid-direct-tag-checks.md +54 -0
  12. package/patterns/avoid-expect-in-if.md +52 -0
  13. package/patterns/avoid-mutable-state.md +70 -0
  14. package/patterns/avoid-native-fetch.md +61 -0
  15. package/patterns/avoid-node-imports.md +86 -0
  16. package/patterns/avoid-non-null-assertion.md +44 -0
  17. package/patterns/avoid-object-type.md +46 -0
  18. package/patterns/avoid-option-getorthrow.md +39 -0
  19. package/patterns/avoid-platform-coupling.md +43 -0
  20. package/patterns/avoid-process-env.md +43 -0
  21. package/patterns/avoid-react-hooks.md +73 -0
  22. package/patterns/avoid-schema-suffix.md +45 -0
  23. package/patterns/avoid-sync-fs.md +68 -0
  24. package/patterns/avoid-try-catch.md +47 -0
  25. package/patterns/avoid-ts-ignore.md +38 -0
  26. package/patterns/avoid-untagged-errors.md +67 -0
  27. package/patterns/avoid-yield-ref.md +46 -0
  28. package/patterns/casting-awareness.md +46 -0
  29. package/patterns/context-tag-extends.md +84 -0
  30. package/patterns/effect-catchall-default.md +61 -0
  31. package/patterns/effect-promise-vs-trypromise.md +47 -0
  32. package/patterns/effect-run-in-body.md +58 -0
  33. package/patterns/imperative-loops.md +76 -0
  34. package/patterns/prefer-arr-sort.md +52 -0
  35. package/patterns/prefer-duration-values.md +56 -0
  36. package/patterns/prefer-effect-fn.md +161 -0
  37. package/patterns/prefer-match-over-switch.md +48 -0
  38. package/patterns/prefer-option-over-null.md +56 -0
  39. package/patterns/prefer-redacted-config.md +70 -0
  40. package/patterns/prefer-schema-class.md +54 -0
  41. package/patterns/require-effect-concurrency.md +83 -0
  42. package/patterns/stream-large-files.md +63 -0
  43. package/patterns/throw-in-effect-gen.md +62 -0
  44. package/patterns/use-clock-service.md +45 -0
  45. package/patterns/use-command-executor-service.md +54 -0
  46. package/patterns/use-console-service.md +54 -0
  47. package/patterns/use-filesystem-service.md +59 -0
  48. package/patterns/use-http-client-service.md +77 -0
  49. package/patterns/use-path-service.md +53 -0
  50. package/patterns/use-random-service.md +45 -0
  51. package/patterns/use-temp-file-scoped.md +66 -0
  52. package/patterns/vm-in-wrong-file.md +51 -0
  53. package/patterns/yield-in-for-loop.md +61 -0
  54. package/skills/effect-ai-chat/SKILL.md +472 -0
  55. package/skills/effect-ai-language-model/SKILL.md +652 -0
  56. package/skills/effect-ai-prompt/SKILL.md +752 -0
  57. package/skills/effect-ai-provider/SKILL.md +668 -0
  58. package/skills/effect-ai-streaming/SKILL.md +418 -0
  59. package/skills/effect-ai-tool/SKILL.md +1132 -0
  60. package/skills/effect-atom-rpc/SKILL.md +488 -0
  61. package/skills/effect-atom-state/SKILL.md +640 -0
  62. package/skills/effect-batching/SKILL.md +614 -0
  63. package/skills/effect-cache/SKILL.md +570 -0
  64. package/skills/effect-cli/SKILL.md +523 -0
  65. package/skills/effect-command-executor/SKILL.md +675 -0
  66. package/skills/effect-concurrency-testing/SKILL.md +612 -0
  67. package/skills/effect-config/SKILL.md +580 -0
  68. package/skills/effect-context-witness/SKILL.md +274 -0
  69. package/skills/effect-domain-modeling/SKILL.md +1212 -0
  70. package/skills/effect-domain-predicates/SKILL.md +867 -0
  71. package/skills/effect-error-handling/SKILL.md +1581 -0
  72. package/skills/effect-fiber/SKILL.md +731 -0
  73. package/skills/effect-filesystem/SKILL.md +624 -0
  74. package/skills/effect-graph/SKILL.md +571 -0
  75. package/skills/effect-http-api/SKILL.md +1760 -0
  76. package/skills/effect-http-client/SKILL.md +989 -0
  77. package/skills/effect-http-server/SKILL.md +920 -0
  78. package/skills/effect-incremental-migration/SKILL.md +362 -0
  79. package/skills/effect-layer-design/SKILL.md +642 -0
  80. package/skills/effect-managed-runtime/SKILL.md +395 -0
  81. package/skills/effect-mcp-server/SKILL.md +608 -0
  82. package/skills/effect-observability/SKILL.md +719 -0
  83. package/skills/effect-optics/SKILL.md +554 -0
  84. package/skills/effect-parallelization/SKILL.md +668 -0
  85. package/skills/effect-path/SKILL.md +296 -0
  86. package/skills/effect-pattern-matching/SKILL.md +914 -0
  87. package/skills/effect-platform-abstraction/SKILL.md +1175 -0
  88. package/skills/effect-platform-layers/SKILL.md +514 -0
  89. package/skills/effect-pubsub-event-bus/SKILL.md +384 -0
  90. package/skills/effect-react-composition/SKILL.md +986 -0
  91. package/skills/effect-react-vm/SKILL.md +675 -0
  92. package/skills/effect-rpc-api/SKILL.md +624 -0
  93. package/skills/effect-rpc-client/SKILL.md +666 -0
  94. package/skills/effect-rpc-cluster/SKILL.md +1623 -0
  95. package/skills/effect-rpc-server/SKILL.md +767 -0
  96. package/skills/effect-scheduling/SKILL.md +124 -0
  97. package/skills/effect-schema-composition/SKILL.md +975 -0
  98. package/skills/effect-schema-v4/SKILL.md +691 -0
  99. package/skills/effect-scope/SKILL.md +682 -0
  100. package/skills/effect-service-implementation/SKILL.md +656 -0
  101. package/skills/effect-socket/SKILL.md +703 -0
  102. package/skills/effect-sql/SKILL.md +781 -0
  103. package/skills/effect-stream/SKILL.md +765 -0
  104. package/skills/effect-testing/SKILL.md +1331 -0
  105. package/skills/effect-typeclass-design/SKILL.md +161 -0
  106. package/skills/effect-wide-events/Article.md +66 -0
  107. package/skills/effect-wide-events/SKILL.md +95 -0
  108. package/skills/effect-workflow/SKILL.md +810 -0
  109. package/src/agent-policy.ts +22 -0
  110. package/src/enforcer.ts +104 -0
  111. package/src/frontmatter.ts +34 -0
  112. package/src/guidance.ts +66 -0
  113. package/src/index.ts +38 -0
  114. package/src/pattern-catalog.ts +115 -0
  115. package/src/pattern-matcher.ts +178 -0
  116. package/src/pattern.ts +97 -0
  117. package/src/skills.ts +29 -0
  118. package/src/write-projection.ts +66 -0
@@ -0,0 +1,810 @@
1
+ ---
2
+ name: effect-workflow
3
+ description: Build durable workflows with Effect using Workflow, Activity, DurableClock, DurableDeferred, and DurableQueue for execution that survives restarts, supports compensation (saga pattern), and integrates with Effect Cluster for distribution.
4
+ ---
5
+
6
+ You are an Effect TypeScript expert specializing in durable workflow execution using the `effect/unstable/workflow` module.
7
+
8
+ ## Effect Source Reference
9
+
10
+ The Effect v4 source is available at `~/.local/share/opencode/repos/github.com/Effect-TS/effect@main/`.
11
+ Browse and read files there directly to look up APIs, types, and implementations.
12
+
13
+ Key source files:
14
+
15
+ - `packages/effect/src/unstable/workflow/Workflow.ts` — Workflow definition, compensation, annotations
16
+ - `packages/effect/src/unstable/workflow/Activity.ts` — Activity definition, retry, idempotency
17
+ - `packages/effect/src/unstable/workflow/WorkflowEngine.ts` — Engine service, in-memory layer, encoded interface
18
+ - `packages/effect/src/unstable/workflow/DurableClock.ts` — Durable sleep/timers
19
+ - `packages/effect/src/unstable/workflow/DurableDeferred.ts` — Durable signal/wait, tokens, done/succeed/fail
20
+ - `packages/effect/src/unstable/workflow/DurableQueue.ts` — Durable queue handing work to persisted background workers
21
+
22
+ ## IMPORTANT: Unstable API
23
+
24
+ The workflow module lives under `effect/unstable/workflow`. APIs may change between versions. All imports use this path:
25
+
26
+ ```ts
27
+ import {
28
+ Workflow,
29
+ Activity,
30
+ WorkflowEngine,
31
+ DurableClock,
32
+ DurableDeferred,
33
+ DurableQueue
34
+ } from 'effect/unstable/workflow';
35
+ ```
36
+
37
+ ## Core Concepts
38
+
39
+ Effect Workflow provides **durable execution** — workflows that survive process restarts through event sourcing. The key idea: activities are the units of side-effectful work whose results get persisted. On replay, persisted results are returned without re-executing the activity.
40
+
41
+ ### Architecture
42
+
43
+ ```
44
+ Workflow → defines the overall process (name, payload, success/error schemas)
45
+ Activity → a discrete unit of work inside a workflow (results are persisted)
46
+ WorkflowEngine → orchestrates execution, replay, suspension, resumption
47
+ DurableClock → sleep/timer that persists across restarts
48
+ DurableDeferred → wait-for-external-signal that persists across restarts
49
+ DurableQueue → hand work to a persisted background worker and await its result
50
+ ```
51
+
52
+ ## Workflow Definition
53
+
54
+ Use `Workflow.make` to define a workflow. Every workflow has:
55
+
56
+ - A unique `name`
57
+ - A `payload` schema (struct fields or Schema)
58
+ - An `idempotencyKey` function that produces a deterministic execution ID from the payload
59
+ - Optional `success` and `error` schemas (default `Schema.Void` / `Schema.Never`)
60
+
61
+ ```ts
62
+ import { Workflow } from 'effect/unstable/workflow';
63
+ import { Schema } from 'effect';
64
+
65
+ const SendEmail = Workflow.make({
66
+ name: 'SendEmail',
67
+ payload: {
68
+ to: Schema.String,
69
+ subject: Schema.String,
70
+ body: Schema.String
71
+ },
72
+ idempotencyKey: (payload) => `${payload.to}:${payload.subject}`,
73
+ success: Schema.Void,
74
+ error: Schema.String
75
+ });
76
+ ```
77
+
78
+ ### Registering a Workflow Handler
79
+
80
+ Use `workflow.toLayer(handler)` to register the execution logic. The handler receives the decoded payload and execution ID:
81
+
82
+ ```ts
83
+ const SendEmailLive = SendEmail.toLayer((payload, executionId) =>
84
+ Effect.gen(function* () {
85
+ // Activities go here — their results are persisted
86
+ yield* validateRecipient; // an Activity
87
+ yield* sendViaProvider; // an Activity
88
+ })
89
+ );
90
+ ```
91
+
92
+ ### Executing a Workflow
93
+
94
+ ```ts
95
+ // Execute and wait for result
96
+ const result =
97
+ yield*
98
+ SendEmail.execute({
99
+ to: 'user@example.com',
100
+ subject: 'Hello',
101
+ body: 'World'
102
+ });
103
+
104
+ // Fire-and-forget — returns the execution ID
105
+ const executionId =
106
+ yield*
107
+ SendEmail.execute(
108
+ { to: 'user@example.com', subject: 'Hello', body: 'World' },
109
+ { discard: true }
110
+ );
111
+
112
+ // Poll for result
113
+ const maybeResult = yield* SendEmail.poll(executionId);
114
+ // returns Effect<Option<Result<A, E>>>
115
+ // Option.None → workflow not yet started or no record
116
+ // Option.Some(Workflow.Complete<A, E>) → finished; .exit holds the Exit
117
+ // Option.Some(Workflow.Suspended) → suspended waiting on something
118
+
119
+ // Interrupt a running workflow
120
+ yield* SendEmail.interrupt(executionId);
121
+
122
+ // Resume a suspended workflow
123
+ yield* SendEmail.resume(executionId);
124
+ ```
125
+
126
+ ### Deterministic Execution ID
127
+
128
+ The execution ID is computed as a hash of `"${name}-${idempotencyKey(payload)}"`. This means executing the same workflow with the same payload is idempotent — it returns the existing execution rather than starting a new one.
129
+
130
+ ```ts
131
+ const id =
132
+ yield*
133
+ SendEmail.executionId({
134
+ to: 'user@example.com',
135
+ subject: 'Hello',
136
+ body: 'World'
137
+ });
138
+ ```
139
+
140
+ ## Activities
141
+
142
+ Activities are the **atomic units of work** inside a workflow. Their results are persisted by the engine, so on replay they return the cached result without re-executing.
143
+
144
+ ```ts
145
+ import { Activity } from 'effect/unstable/workflow';
146
+
147
+ const validateRecipient = Activity.make({
148
+ name: 'ValidateRecipient',
149
+ success: Schema.Struct({ valid: Schema.Boolean }),
150
+ error: Schema.String,
151
+ execute: Effect.gen(function* () {
152
+ // This code runs at most once per workflow execution
153
+ // (unless the activity itself fails and is retried)
154
+ const result = yield* checkEmailService(payload.to);
155
+ return { valid: result.isValid };
156
+ })
157
+ });
158
+ ```
159
+
160
+ ### Activity is an Effect
161
+
162
+ An `Activity` extends `Effect.Effect` (it is implemented via `Effectable.Prototype`), so you can yield it directly inside a workflow handler:
163
+
164
+ ```ts
165
+ const handler = SendEmail.toLayer((payload, executionId) =>
166
+ Effect.gen(function* () {
167
+ const validation = yield* validateRecipient; // yields the Activity directly
168
+ })
169
+ );
170
+ ```
171
+
172
+ ### Activity Retry
173
+
174
+ Use `Activity.retry` to retry an effect within an activity. The engine tracks the attempt count automatically and exposes it via `Activity.CurrentAttempt`:
175
+
176
+ ```ts
177
+ import { Activity } from 'effect/unstable/workflow';
178
+
179
+ const sendWithRetry = Activity.make({
180
+ name: 'SendWithRetry',
181
+ success: Schema.Void,
182
+ error: Schema.String,
183
+ execute: pipe(sendEmailEffect, Activity.retry({ times: 3 }))
184
+ });
185
+ ```
186
+
187
+ `Activity.retry` accepts the same options as `Effect.retry` *minus* `schedule` — the activity owns the attempt counter, so retries are attempt-based (`times`, `until`, `while`, `catch`, etc.) rather than schedule-based.
188
+
189
+ Because an `Activity` *is* an `Effect`, pipe it directly through compensation/retry combinators — there is no `.asEffect()` method (`Effect.Yieldable` was removed in beta.66):
190
+
191
+ ```ts
192
+ yield* SomeActivity.pipe(
193
+ workflow.withCompensation((value, cause) => rollback(value)),
194
+ Activity.retry({ times: 5 })
195
+ );
196
+ ```
197
+
198
+ You can also access `Activity.CurrentAttempt` directly inside an activity's `execute` to branch by attempt:
199
+
200
+ ```ts
201
+ Activity.make({
202
+ name: 'SendEmail',
203
+ execute: Effect.gen(function*() {
204
+ const attempt = yield* Activity.CurrentAttempt;
205
+ if (attempt < 5) return yield* Effect.fail(new TransientError());
206
+ return yield* sendEmail();
207
+ })
208
+ });
209
+ ```
210
+
211
+ Activities also expose `.execute` and `.executeEncoded` properties — the latter returns the JSON-encoded form of success/error, useful for generic activity wrappers and logging.
212
+
213
+ ### Interrupt Retry Policy
214
+
215
+ Activities have a built-in `interruptRetryPolicy` — if an activity is interrupted (e.g., by process shutdown), it automatically retries with exponential backoff up to 10 times. You can override this:
216
+
217
+ ```ts
218
+ Activity.make({
219
+ name: 'LongRunning',
220
+ execute: longRunningEffect,
221
+ interruptRetryPolicy: Schedule.recurs(5)
222
+ });
223
+ ```
224
+
225
+ ### Idempotency Keys
226
+
227
+ Generate deterministic idempotency keys for external API calls within activities:
228
+
229
+ ```ts
230
+ const key = yield* Activity.idempotencyKey('stripe-charge');
231
+ // Incorporates the execution ID + activity name
232
+
233
+ // Include the attempt number for retry-aware keys
234
+ const keyWithAttempt =
235
+ yield*
236
+ Activity.idempotencyKey('stripe-charge', {
237
+ includeAttempt: true
238
+ });
239
+ ```
240
+
241
+ ### Racing Activities
242
+
243
+ Race multiple activities — the first to complete wins, and the result is durably stored:
244
+
245
+ ```ts
246
+ const result =
247
+ yield*
248
+ Activity.raceAll('fastest-provider', [
249
+ sendViaProviderA,
250
+ sendViaProviderB,
251
+ sendViaProviderC
252
+ ]);
253
+ ```
254
+
255
+ ## DurableClock — Durable Sleep
256
+
257
+ `DurableClock.sleep` creates a timer that survives process restarts. Short sleeps (<=60s by default) run in-memory as regular activities. Longer sleeps are scheduled through the engine.
258
+
259
+ ```ts
260
+ import { DurableClock } from 'effect/unstable/workflow';
261
+
262
+ // Inside a workflow handler:
263
+ yield*
264
+ DurableClock.sleep({
265
+ name: 'wait-before-retry',
266
+ duration: '30 minutes'
267
+ });
268
+
269
+ // Customize the in-memory threshold (default 60 seconds)
270
+ yield*
271
+ DurableClock.sleep({
272
+ name: 'cooldown',
273
+ duration: '5 minutes',
274
+ inMemoryThreshold: '2 minutes' // sleeps <= 2min run in-memory
275
+ });
276
+ ```
277
+
278
+ Under the hood, a DurableClock creates a `DurableDeferred` and the engine schedules a wake-up after the duration elapses.
279
+
280
+ ## DurableDeferred — Wait for External Signals
281
+
282
+ `DurableDeferred` lets a workflow pause and wait for an external event (e.g., a webhook, user approval, payment confirmation). The state is persisted, so the workflow can resume after restart.
283
+
284
+ ### Creating and Awaiting
285
+
286
+ ```ts
287
+ import { DurableDeferred } from 'effect/unstable/workflow';
288
+ import { Schema, Exit } from 'effect';
289
+
290
+ // Define the deferred with typed schemas
291
+ const PaymentConfirmation = DurableDeferred.make('payment-confirmation', {
292
+ success: Schema.Struct({ transactionId: Schema.String }),
293
+ error: Schema.String
294
+ });
295
+
296
+ // Inside a workflow: wait for the signal
297
+ const confirmation = yield* DurableDeferred.await(PaymentConfirmation);
298
+ ```
299
+
300
+ The engine registers the awaited deferred before reading it. Completing that deferred while the workflow run is still active preempts a run parked on it, retains the pending result, and replays so the completion is observed. The in-memory engine follows the same behavior as `ClusterWorkflowEngine`; this closes the race where a live completion could otherwise be missed until a later retry.
301
+
302
+ ### Completing from Outside
303
+
304
+ External code (e.g., a webhook handler) completes the deferred using a **token**:
305
+
306
+ ```ts
307
+ // Inside the workflow: generate a token to give to external systems
308
+ const token = yield* DurableDeferred.token(PaymentConfirmation);
309
+ // token is a branded string encoding workflow + execution + deferred name
310
+
311
+ // --- Later, from outside the workflow (e.g., webhook handler): ---
312
+
313
+ // Succeed
314
+ yield*
315
+ DurableDeferred.succeed(PaymentConfirmation, {
316
+ token,
317
+ value: { transactionId: 'tx_123' }
318
+ });
319
+
320
+ // Or fail
321
+ yield*
322
+ DurableDeferred.fail(PaymentConfirmation, {
323
+ token,
324
+ error: 'Payment declined'
325
+ });
326
+
327
+ // Or use done() with a full Exit
328
+ yield*
329
+ DurableDeferred.done(PaymentConfirmation, {
330
+ token,
331
+ exit: Exit.succeed({ transactionId: 'tx_123' })
332
+ });
333
+ ```
334
+
335
+ ### Token Generation Without Being Inside a Workflow
336
+
337
+ You can generate tokens from outside a workflow if you know the workflow and payload:
338
+
339
+ ```ts
340
+ // From execution ID
341
+ const token = DurableDeferred.tokenFromExecutionId(PaymentConfirmation, {
342
+ workflow: SendEmail,
343
+ executionId: 'abc123'
344
+ });
345
+
346
+ // From payload (computes the execution ID)
347
+ const token =
348
+ yield*
349
+ DurableDeferred.tokenFromPayload(PaymentConfirmation, {
350
+ workflow: SendEmail,
351
+ payload: { to: 'user@example.com', subject: 'Hello', body: 'World' }
352
+ });
353
+ ```
354
+
355
+ ### Token Parsing
356
+
357
+ Tokens are base64url-encoded and can be parsed:
358
+
359
+ ```ts
360
+ const parsed = DurableDeferred.TokenParsed.fromString(token);
361
+ // { workflowName: "SendEmail", executionId: "abc123", deferredName: "payment-confirmation" }
362
+ ```
363
+
364
+ ### Piping an Effect into a DurableDeferred
365
+
366
+ `DurableDeferred.into` runs an effect and stores its result in the deferred on completion:
367
+
368
+ ```ts
369
+ yield* pipe(someEffect, DurableDeferred.into(PaymentConfirmation));
370
+ ```
371
+
372
+ ### Racing with DurableDeferred
373
+
374
+ `DurableDeferred.raceAll` races multiple effects and durably stores the first result:
375
+
376
+ ```ts
377
+ const result =
378
+ yield*
379
+ DurableDeferred.raceAll({
380
+ name: 'first-response',
381
+ success: Schema.String,
382
+ error: Schema.Never,
383
+ effects: [fetchFromA, fetchFromB]
384
+ });
385
+ ```
386
+
387
+ ## DurableQueue — Hand Work to Background Workers
388
+
389
+ `DurableQueue` lets a workflow delegate a unit of work to a **persisted background worker** and suspend until the worker records a result. The workflow calls `process` to enqueue an item and wait; a separate worker created with `worker` / `makeWorker` takes the item, runs the handler, and completes the waiting workflow through a `DurableDeferred` token.
390
+
391
+ ```ts
392
+ import { DurableQueue, Workflow, WorkflowEngine } from 'effect/unstable/workflow';
393
+ import { PersistedQueue } from 'effect/unstable/persistence';
394
+ import { Effect, Layer, Schema } from 'effect';
395
+ ```
396
+
397
+ ### Defining a queue — `DurableQueue.make`
398
+
399
+ ```ts
400
+ const ApiQueue = DurableQueue.make({
401
+ name: 'ApiQueue',
402
+ payload: { id: Schema.String },
403
+ success: Schema.Void, // default Schema.Void
404
+ error: Schema.Never, // default Schema.Never
405
+ idempotencyKey: (payload) => payload.id
406
+ });
407
+ ```
408
+
409
+ The `name`, the payload/success/error schemas, and the `idempotencyKey` are **persisted coordination state**. Keep them deterministic and stable across deployments — changing them is a persistence migration. The `idempotencyKey` becomes the persisted queue item id.
410
+
411
+ ### Producing — `DurableQueue.process`
412
+
413
+ Call `process` from inside a workflow handler. It encodes the payload, offers it to the persisted queue with a deferred token, suspends the workflow, and resumes with the worker's typed success or error:
414
+
415
+ ```ts
416
+ const MyWorkflowLayer = MyWorkflow.toLayer((payload) =>
417
+ Effect.gen(function* () {
418
+ yield* DurableQueue.process(ApiQueue, { id: 'api-call-1' });
419
+ // resumes here once a worker records the result
420
+ })
421
+ );
422
+ ```
423
+
424
+ `process(queue, payload, { retrySchedule? })` requires **`WorkflowEngine | WorkflowInstance | PersistedQueue.PersistedQueueFactory`** in context — it runs as activity-style work *inside* a running workflow. `retrySchedule` only governs retries of transient `PersistedQueueError`s while offering the item, not the handler.
425
+
426
+ ### Consuming — `DurableQueue.worker` / `makeWorker`
427
+
428
+ `worker` returns a `Layer` that forks background workers; `makeWorker` returns the underlying `Effect<never>` if you want to fork it yourself:
429
+
430
+ ```ts
431
+ const ApiWorker = DurableQueue.worker(
432
+ ApiQueue,
433
+ (payload) => Effect.log(`processing ${payload.id}`),
434
+ { concurrency: 5 } // process up to 5 items concurrently; default 1
435
+ );
436
+ ```
437
+
438
+ `worker` / `makeWorker` require **`WorkflowEngine | PersistedQueue.PersistedQueueFactory`** (plus the handler's own `R`). Note they do **not** require `WorkflowInstance`, unlike `process` — workers run outside any single workflow instance.
439
+
440
+ ### Service requirements & idempotency
441
+
442
+ Provide a `PersistedQueueFactory`. Tests wire the in-memory store:
443
+
444
+ ```ts
445
+ const PersistedQueueLayer = PersistedQueue.layer.pipe(
446
+ Layer.provideMerge(PersistedQueue.layerStoreMemory)
447
+ );
448
+
449
+ const AppLayer = Layer.mergeAll(MyWorkflowLayer, ApiWorker).pipe(
450
+ Layer.provideMerge(WorkflowEngine.layerMemory),
451
+ Layer.provideMerge(PersistedQueueLayer)
452
+ );
453
+ ```
454
+
455
+ Delivery is **at least once** per the backing `PersistedQueue`, so worker handlers must be **idempotent** and tolerate retries, duplicate observations, and worker restarts.
456
+
457
+ ## Compensation (Saga Pattern)
458
+
459
+ `Workflow.withCompensation` registers rollback logic that runs if the **entire workflow** fails. This enables the saga pattern for distributed transactions.
460
+
461
+ ```ts
462
+ const handler = OrderWorkflow.toLayer((payload, executionId) =>
463
+ Effect.gen(function* () {
464
+ // Reserve inventory — if workflow fails later, compensate
465
+ const reservation = yield* pipe(
466
+ reserveInventory,
467
+ OrderWorkflow.withCompensation((reservationId, cause) =>
468
+ cancelReservation(reservationId)
469
+ )
470
+ );
471
+
472
+ // Charge payment — if workflow fails later, compensate
473
+ const charge = yield* pipe(
474
+ chargePayment,
475
+ OrderWorkflow.withCompensation((chargeId, cause) =>
476
+ refundPayment(chargeId)
477
+ )
478
+ );
479
+
480
+ // Ship order — no compensation needed for the last step
481
+ yield* shipOrder(reservation, charge);
482
+ })
483
+ );
484
+ ```
485
+
486
+ The compensation function receives:
487
+
488
+ 1. The **success value** of the compensated effect (so you can use it for rollback)
489
+ 2. The **Cause** of the workflow failure
490
+
491
+ **Important**: Compensation only works for top-level effects in the workflow, not for nested activities.
492
+
493
+ ## Workflow Annotations
494
+
495
+ ### CaptureDefects
496
+
497
+ Controls whether defects (unexpected errors) are captured in the workflow result. Default: `true`.
498
+
499
+ ```ts
500
+ const MyWorkflow = Workflow.make({ ... }).annotate(Workflow.CaptureDefects, false)
501
+ ```
502
+
503
+ ### SuspendOnFailure
504
+
505
+ When `true`, the workflow suspends on any error instead of failing. You can then manually resume it:
506
+
507
+ ```ts
508
+ const MyWorkflow = Workflow.make({ ... }).annotate(Workflow.SuspendOnFailure, true)
509
+
510
+ // Later, after fixing the issue:
511
+ yield* MyWorkflow.resume(executionId)
512
+ ```
513
+
514
+ ## Workflow Scope
515
+
516
+ Access the workflow's scope, which lives for the entire execution (across replays):
517
+
518
+ ```ts
519
+ // Get the scope
520
+ const workflowScope = yield* Workflow.scope;
521
+
522
+ // Provide scope to a scoped effect
523
+ yield* Workflow.provideScope(myScopedEffect);
524
+
525
+ // Add a finalizer to the workflow scope
526
+ yield*
527
+ Workflow.addFinalizer((exit) =>
528
+ Effect.log(`Workflow completed with: ${exit}`)
529
+ );
530
+ ```
531
+
532
+ Use `Workflow.addFinalizer` for terminal work that must observe an interrupt deposited through `Workflow.interrupt`. A body-level `Effect.onExit` finalizer cannot observe that deposited workflow interrupt; workflow-scope finalization is deliberately aligned between the in-memory and cluster engines.
533
+
534
+ ## WorkflowEngine
535
+
536
+ The `WorkflowEngine` is a service that orchestrates workflow execution. It handles registration, execution, replay, suspension, and resumption.
537
+
538
+ ### In-Memory Engine (Testing/Development)
539
+
540
+ For testing and local development, use the in-memory engine:
541
+
542
+ ```ts
543
+ import { WorkflowEngine } from 'effect/unstable/workflow';
544
+
545
+ const TestLayer = Layer.mergeAll(
546
+ SendEmailLive
547
+ // ... other workflow registrations
548
+ ).pipe(Layer.provideMerge(WorkflowEngine.layerMemory));
549
+ ```
550
+
551
+ **Warning**: The in-memory engine does NOT provide durability guarantees. Use it only for testing.
552
+
553
+ ### Production Engine — `ClusterWorkflowEngine.layer`
554
+
555
+ For production, use `ClusterWorkflowEngine.layer` from `effect/unstable/cluster`. It wires the workflow engine into `Sharding` + `MessageStorage` so executions, activities, and durable signals survive restarts and can be distributed across runners:
556
+
557
+ ```ts
558
+ import { Layer } from 'effect';
559
+ import { ClusterWorkflowEngine } from 'effect/unstable/cluster';
560
+
561
+ const WorkflowsLayer = Layer.mergeAll(
562
+ SendEmailLive,
563
+ ProcessOrderLive
564
+ ).pipe(Layer.provideMerge(ClusterWorkflowEngine.layer));
565
+
566
+ // then provide the cluster bundle (NodeClusterSocket.layer / SingleRunner.layer / TestRunner.layer)
567
+ ```
568
+
569
+ The `ClusterWorkflowEngine` requires `Sharding | MessageStorage` in context; both come from any of the cluster runtime bundles. See the `effect-rpc-cluster` skill for cluster setup.
570
+
571
+ #### Workflow shard-group routing
572
+
573
+ A workflow can be annotated with `ClusterSchema.ShardGroup` (from `effect/unstable/cluster`), exactly like an entity:
574
+
575
+ ```ts
576
+ import { ClusterSchema } from 'effect/unstable/cluster';
577
+
578
+ const OrderWorkflow = Workflow.make({ /* ... */ })
579
+ .annotate(ClusterSchema.ShardGroup, () => 'workflow');
580
+ ```
581
+
582
+ `ClusterWorkflowEngine` reads that annotation when computing the workflow entity's address, so the workflow's entity messages, durable clock wake-ups, and registered durable-deferred completions all route through the owning workflow's shard group. If you use any group other than `'default'`, you must include it on the appropriate runners via `ShardingConfig.availableShardGroups` / `assignedShardGroups` (e.g. `['default', 'workflow']`) — otherwise those messages have nowhere to land. See the `effect-rpc-cluster` skill for `ShardingConfig` details.
583
+
584
+ `ClusterWorkflowEngine` gives workflow execution entities and the durable-clock entity a fixed `10 seconds` idle timeout. Completed and suspended executions release the runner's bounded entity-residency slots quickly; because their state is durable, the next workflow, deferred, or clock message recreates the entity from storage. This is intentional passivation, not loss of a suspended workflow.
585
+
586
+ ### Custom Engine Implementation
587
+
588
+ For production, implement the `WorkflowEngine.Encoded` interface and use `WorkflowEngine.makeUnsafe`:
589
+
590
+ ```ts
591
+ const engine = WorkflowEngine.makeUnsafe({
592
+ register: (workflow, execute) => ...,
593
+ // execute receives { executionId, payload, discard, parent? }. The engine
594
+ // passes `parent` even for discard executions, so child interruption links
595
+ // back to the parent workflow before the deterministic execution id returns.
596
+ execute: (workflow, { executionId, payload, discard, parent }) => ...,
597
+ poll: (workflow, executionId) => ...,
598
+ interrupt: (workflow, executionId) => ...,
599
+ // Required by WorkflowEngine.Encoded. interruptUnsafe is a more
600
+ // direct stop that CAN bypass compensation and parent/child cleanup
601
+ // guarantees that `interrupt` upholds — prefer `interrupt` unless you
602
+ // explicitly need the harder stop.
603
+ interruptUnsafe: (workflow, executionId) => ...,
604
+ resume: (workflow, executionId) => ...,
605
+ activityExecute: (activity, attempt) => ...,
606
+ deferredResult: (deferred) => ...,
607
+ deferredDone: (options) => ...,
608
+ scheduleClock: (workflow, options) => ...
609
+ })
610
+ ```
611
+
612
+ The `Encoded` interface works with raw/encoded values (JSON-safe), while the `WorkflowEngine` service handles schema encoding/decoding automatically.
613
+
614
+ ### Suspended Retry Schedule
615
+
616
+ When a workflow suspends (waiting for an activity or deferred), the engine retries with `Schedule.min([Schedule.exponential("200 millis", 1.5), Schedule.spaced("30 seconds")])`: exponential backoff from 200ms, capped at 30s. (`Schedule.andThen` / `andThenResult` were renamed to `Schedule.concat` / `concatResult`, and the old `Schedule.either` cap pattern is now `Schedule.min`.) Override per-workflow:
617
+
618
+ ```ts
619
+ const MyWorkflow = Workflow.make({
620
+ name: 'MyWorkflow',
621
+ payload: { id: Schema.String },
622
+ idempotencyKey: (p) => p.id,
623
+ suspendedRetrySchedule: Schedule.spaced('5 seconds')
624
+ });
625
+ ```
626
+
627
+ ## Complete Example: Order Processing Workflow
628
+
629
+ ```ts
630
+ import { Effect, Exit, Layer, Schema, pipe } from 'effect';
631
+ import {
632
+ Activity,
633
+ DurableClock,
634
+ DurableDeferred,
635
+ Workflow,
636
+ WorkflowEngine
637
+ } from 'effect/unstable/workflow';
638
+
639
+ // --- Schemas ---
640
+
641
+ class OrderError extends Schema.TaggedClass<OrderError>()('OrderError', {
642
+ message: Schema.String
643
+ }) {}
644
+
645
+ // --- Deferred for external payment confirmation ---
646
+
647
+ const PaymentApproval = DurableDeferred.make('payment-approval', {
648
+ success: Schema.Struct({ transactionId: Schema.String }),
649
+ error: OrderError
650
+ });
651
+
652
+ // --- Activities ---
653
+
654
+ const validateOrder = Activity.make({
655
+ name: 'ValidateOrder',
656
+ success: Schema.Struct({ valid: Schema.Boolean }),
657
+ error: OrderError,
658
+ execute: Effect.gen(function* () {
659
+ // validate order details
660
+ return { valid: true };
661
+ })
662
+ });
663
+
664
+ const reserveInventory = Activity.make({
665
+ name: 'ReserveInventory',
666
+ success: Schema.Struct({ reservationId: Schema.String }),
667
+ error: OrderError,
668
+ execute: Effect.gen(function* () {
669
+ const key = yield* Activity.idempotencyKey('reserve');
670
+ // call inventory service with idempotency key
671
+ return { reservationId: 'res_001' };
672
+ })
673
+ });
674
+
675
+ const shipOrder = Activity.make({
676
+ name: 'ShipOrder',
677
+ success: Schema.Void,
678
+ error: OrderError,
679
+ execute: Effect.gen(function* () {
680
+ // call shipping service
681
+ })
682
+ });
683
+
684
+ // --- Workflow ---
685
+
686
+ const ProcessOrder = Workflow.make({
687
+ name: 'ProcessOrder',
688
+ payload: {
689
+ orderId: Schema.String,
690
+ items: Schema.Array(Schema.String)
691
+ },
692
+ idempotencyKey: (payload) => payload.orderId,
693
+ success: Schema.Struct({ transactionId: Schema.String }),
694
+ error: OrderError
695
+ });
696
+
697
+ const ProcessOrderLive = ProcessOrder.toLayer((payload, executionId) =>
698
+ Effect.gen(function* () {
699
+ // Step 1: Validate
700
+ yield* validateOrder;
701
+
702
+ // Step 2: Reserve inventory with compensation
703
+ const reservation = yield* pipe(
704
+ reserveInventory,
705
+ ProcessOrder.withCompensation(({ reservationId }, _cause) =>
706
+ Effect.log(`Cancelling reservation ${reservationId}`)
707
+ )
708
+ );
709
+
710
+ // Step 3: Wait for payment (external signal)
711
+ const payment = yield* DurableDeferred.await(PaymentApproval);
712
+
713
+ // Step 4: Wait before shipping
714
+ yield* DurableClock.sleep({
715
+ name: 'pre-ship-delay',
716
+ duration: '5 minutes'
717
+ });
718
+
719
+ // Step 5: Ship
720
+ yield* shipOrder;
721
+
722
+ return payment;
723
+ })
724
+ );
725
+
726
+ // --- Running ---
727
+
728
+ const MainLive = ProcessOrderLive.pipe(
729
+ Layer.provideMerge(WorkflowEngine.layerMemory)
730
+ );
731
+
732
+ // Execute the workflow
733
+ const program = Effect.gen(function* () {
734
+ const executionId = yield* ProcessOrder.execute(
735
+ { orderId: 'order_123', items: ['item_a'] },
736
+ { discard: true }
737
+ );
738
+
739
+ // Later, from a webhook: complete the payment deferred
740
+ const token = DurableDeferred.tokenFromExecutionId(PaymentApproval, {
741
+ workflow: ProcessOrder,
742
+ executionId
743
+ });
744
+ yield* DurableDeferred.succeed(PaymentApproval, {
745
+ token,
746
+ value: { transactionId: 'tx_abc' }
747
+ });
748
+ });
749
+ ```
750
+
751
+ ## Anti-Patterns
752
+
753
+ ### DON'T put side effects outside activities
754
+
755
+ Non-activity code re-executes on every replay. Only put deterministic logic outside activities.
756
+
757
+ ```ts
758
+ // BAD — this HTTP call runs on every replay
759
+ const result = yield* httpClient.get('/api/data');
760
+
761
+ // GOOD — wrap in an activity
762
+ const fetchData = Activity.make({
763
+ name: 'FetchData',
764
+ success: Schema.String,
765
+ execute: httpClient.get('/api/data')
766
+ });
767
+ const result = yield* fetchData;
768
+ ```
769
+
770
+ ### DON'T use non-deterministic logic outside activities
771
+
772
+ Random numbers, current time, UUIDs — these all produce different values on replay.
773
+
774
+ ```ts
775
+ // BAD
776
+ const id = yield* Effect.sync(() => crypto.randomUUID());
777
+
778
+ // GOOD
779
+ const generateId = Activity.make({
780
+ name: 'GenerateId',
781
+ success: Schema.String,
782
+ execute: Effect.sync(() => crypto.randomUUID())
783
+ });
784
+ ```
785
+
786
+ ### DON'T nest compensations inside activities
787
+
788
+ Compensation finalizers are only registered for top-level effects in the workflow.
789
+
790
+ ## Integration with Effect Cluster
791
+
792
+ For production durability and distribution, swap `WorkflowEngine.layerMemory` for `ClusterWorkflowEngine.layer` (from `effect/unstable/cluster`):
793
+
794
+ ```ts
795
+ import { ClusterWorkflowEngine } from 'effect/unstable/cluster';
796
+ import { NodeClusterSocket } from '@effect/platform-node';
797
+
798
+ const MainLive = Layer.mergeAll(SendEmailLive, ProcessOrderLive).pipe(
799
+ Layer.provideMerge(ClusterWorkflowEngine.layer),
800
+ Layer.provide(
801
+ NodeClusterSocket.layer({ storage: 'sql' }).pipe(Layer.provide(SqlClientLayer))
802
+ )
803
+ );
804
+ ```
805
+
806
+ `ClusterWorkflowEngine.layer` builds an `Entity` per workflow under the hood — durable execution state lives in `MessageStorage`, runner-to-runner routing comes from `Sharding`, and replays are driven by the entity's mailbox. The `Workflow.Execution<Name>` type in the context represents the execution identity within the cluster.
807
+
808
+ The internal workflow and durable-clock entities passivate after `10 seconds` of inactivity so they do not retain scarce runner residency while completed or suspended. Plan runner capacity with `ShardingConfig.maxResidentEntities`; persisted wake-ups remain in storage until a slot is available.
809
+
810
+ To expose workflows over RPC or HTTP without writing dispatch glue, use `WorkflowProxy.toRpcGroup` / `WorkflowProxy.toHttpApiGroup` and the matching `WorkflowProxyServer.layerRpcHandlers` / `layerHttpApi`. See the `effect-rpc-cluster` skill for the bridge patterns.