@voltro/cli 0.24.0 → 0.26.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 (90) hide show
  1. package/CHANGELOG.md +670 -0
  2. package/THIRD-PARTY-NOTICES.md +2 -2
  3. package/dist/{apiBuild-B2m4XK_8.js → apiBuild-BtrTyKnF.js} +15 -15
  4. package/dist/apiBuild-DDJ0It4j.js +2 -0
  5. package/dist/bin.js +3 -3
  6. package/dist/{commands-CaIhTsC2.js → commands-D-9iYF2D.js} +1889 -1591
  7. package/dist/dbCommand-DrzXimKf.js +2 -0
  8. package/dist/{dbCommand-CC61CsAc.js → dbCommand-uuNCrFAb.js} +172 -163
  9. package/dist/{dev-DPkQVUTP.js → dev-BnWq4jeA.js} +1 -1
  10. package/dist/{dev-NCuEhNxs.js → dev-Dbm6SWtn.js} +2369 -1774
  11. package/dist/fileConventions-3bffWssN.js +30 -0
  12. package/dist/{frameworkTableAssembly-sg41s14F.js → frameworkTableAssembly-BwHU9Euq.js} +12 -8
  13. package/dist/frameworkTableAssembly-lrjZtk0G.js +2 -0
  14. package/dist/index.js +1 -1
  15. package/dist/{inspect-_ldwsAwH.js → inspect-CUCCzw2I.js} +20 -4
  16. package/dist/inspect-gt8bq-Tz.js +2 -0
  17. package/dist/{inspectMetrics-dcC7gnxu.js → inspectMetrics-BU90mvJN.js} +15 -15
  18. package/dist/{manifestBuild-Bs1Uw22_.js → manifestBuild-BnzAxp2O.js} +1 -1
  19. package/dist/manifestBuild-ifczArzr.js +2 -0
  20. package/dist/serveCommand-XBXuwJty.js +1294 -0
  21. package/dist/serveEntry.js +2 -2
  22. package/dist/{start-C_4VUDT8.js → start-BGXIf6zT.js} +2 -2
  23. package/dist/startEntry.js +2 -2
  24. package/package.json +17 -17
  25. package/templates/AGENTS.core.md +12 -5
  26. package/templates/AGENTS.md +14 -7
  27. package/templates/agent-docs/_index.md +2 -2
  28. package/templates/agent-docs/_manifest.json +3 -3
  29. package/templates/agent-docs/cli.md +15 -0
  30. package/templates/agent-docs/data.md +339 -2
  31. package/templates/agent-docs/database/migrations.md +40 -0
  32. package/templates/agent-docs/database/schema.md +127 -0
  33. package/templates/agent-docs/deployment.md +28 -0
  34. package/templates/agent-docs/plugins.md +26 -4
  35. package/templates/agent-docs/reference.md +2 -2
  36. package/templates/agent-docs/security.md +18 -0
  37. package/templates/agent-docs/templates/apibackends.md +5 -5
  38. package/templates/agent-docs/templates/overview.md +1 -1
  39. package/templates/agent-docs/whats-new.md +278 -168
  40. package/templates/agent-docs/workflows.md +160 -42
  41. package/templates/apps/api-ai/package.json +7 -7
  42. package/templates/apps/api-auth/package.json +8 -8
  43. package/templates/apps/api-backend/package.json +7 -7
  44. package/templates/apps/api-backend-deactivation/package.json +7 -7
  45. package/templates/apps/api-backend-mail/package.json +8 -8
  46. package/templates/apps/api-backend-mariadb/package.json +9 -9
  47. package/templates/apps/api-backend-storage/package.json +8 -8
  48. package/templates/apps/api-data-advanced/package.json +8 -8
  49. package/templates/apps/api-durable/README.md +2 -1
  50. package/templates/apps/api-durable/events/orders.event.ts +18 -0
  51. package/templates/apps/api-durable/mutations/orders.place.mutation.server.ts +17 -15
  52. package/templates/apps/api-durable/package.json +8 -8
  53. package/templates/apps/api-durable/triggers/order.placed.trigger.tsx +6 -4
  54. package/templates/apps/api-feature-flags/package.json +9 -9
  55. package/templates/apps/api-governance/package.json +8 -8
  56. package/templates/apps/api-kv/package.json +8 -8
  57. package/templates/apps/api-moderation/package.json +8 -8
  58. package/templates/apps/api-observability/package.json +8 -8
  59. package/templates/apps/api-ratelimit/package.json +8 -8
  60. package/templates/apps/api-rbac/package.json +8 -8
  61. package/templates/apps/api-rest/package.json +7 -7
  62. package/templates/apps/api-saas/package.json +11 -11
  63. package/templates/apps/api-search/package.json +8 -8
  64. package/templates/apps/api-versioning/package.json +8 -8
  65. package/templates/apps/api-webhooks/README.md +2 -2
  66. package/templates/apps/api-webhooks/events/orders.event.ts +35 -0
  67. package/templates/apps/api-webhooks/package.json +9 -9
  68. package/templates/apps/api-webhooks/template.json +1 -1
  69. package/templates/apps/changelog/package.json +6 -6
  70. package/templates/apps/edge-functions/package.json +2 -2
  71. package/templates/apps/frontend-admin/package.json +8 -8
  72. package/templates/apps/frontend-app/package.json +8 -8
  73. package/templates/apps/frontend-blank/package.json +7 -7
  74. package/templates/apps/frontend-contact/package.json +7 -7
  75. package/templates/apps/frontend-dashboard/package.json +7 -7
  76. package/templates/apps/frontend-docs/package.json +7 -7
  77. package/templates/apps/frontend-i18n/package.json +6 -6
  78. package/templates/apps/frontend-landing/package.json +7 -7
  79. package/templates/apps/frontend-spa/package.json +7 -7
  80. package/templates/apps/frontend-ssr/package.json +7 -7
  81. package/templates/apps/frontend-ssr-api/package.json +8 -8
  82. package/templates/apps/frontend-static-blog/package.json +6 -6
  83. package/dist/apiBuild-UEM3QBke.js +0 -2
  84. package/dist/dbCommand-Cr__4ATv.js +0 -2
  85. package/dist/fileConventions-CBNPZVOV.js +0 -29
  86. package/dist/frameworkTableAssembly-DRJrAVr1.js +0 -2
  87. package/dist/inspect-BA67TF6v.js +0 -2
  88. package/dist/manifestBuild-i-fRHg_H.js +0 -2
  89. package/dist/serveCommand-C4gzDZzc.js +0 -1175
  90. package/templates/apps/api-webhooks/events/order.completed.webhook.tsx +0 -22
@@ -42,7 +42,7 @@ const { runId } = await run.run({})
42
42
  | External side effects that need retry/resume semantics | Workflow |
43
43
  | Human approval or webhook callback in the middle of work | Workflow + `awaitSignal` |
44
44
  | External command that must validate and return a result | Workflow + `awaitUpdate` |
45
- | Domain event that should fan out to durable work | `triggerWorkflow(...)` + `ctx.events.emit(...)` |
45
+ | Domain event that should fan out to durable work | `triggerWorkflow(...)` + `ctx.events.publish(...)` |
46
46
  | One-off durable delay inside an operation | Workflow + `sleep` |
47
47
  | Recurring job such as "daily at 3am" | [Schedule](/docs/scheduling/overview) (`*.cron.tsx`) |
48
48
  | Atomic database write | Mutation |
@@ -225,7 +225,7 @@ export default triggerWorkflow<{ userId: string; plan: string }, { userId: strin
225
225
  Then emit the event from a mutation, action, incoming webhook, schedule, or workflow:
226
226
 
227
227
  ```ts
228
- await ctx.events.emit('user.signup', { userId, plan })
228
+ yield* ctx.events.publish(userSignedUp, { userId }, { plan })
229
229
  ```
230
230
 
231
231
  Voltro records the event in `_voltro_workflow_events`, records each trigger delivery in `_voltro_workflow_event_deliveries`, and starts matching workflows with `source: event:<name>`. Emits inside mutations are post-commit safe: if the mutation rolls back, the workflow fan-out is not launched.
@@ -235,7 +235,7 @@ Use `event: '*'` for audit-style wildcard triggers that should see every emitted
235
235
  ## Runtime boundaries
236
236
 
237
237
  - The workflow executor receives an `AppContext` built for the run. Starts from RPC handlers, mutations, actions, schedules, verified incoming handlers, and inspect tooling record a `source` such as `workflow-rpc`, `app-context`, `schedule:<name>`, `incoming:<id>`, or `inspect`. Request starts also carry the starter trace; authenticated request starts carry the resolved subject into the run record and executor context.
238
- - `ctx.events.emit(...)` is available when the app declares workflow event triggers. Triggered workflow starts carry the starter subject/trace and a `source` of `event:<name>`.
238
+ - `ctx.events.publish(...)` is available when the app declares workflow event triggers. Triggered workflow starts carry the starter subject/trace and a `source` of `event:<name>`.
239
239
  - Put tenant/user ids that the workflow must enforce into the payload. That keeps business authorization deterministic across retries, resumes, and future cross-replica handoff.
240
240
  - Child workflows should be started with `ctx.workflows.child(...)`. Parent lineage and parent-close policy are persisted before the child start, so they survive cross-runner execution.
241
241
  - External incoming handlers created with `defineIncomingWebhook(...)` receive `context.workflows`, so a verified webhook can start, signal, or update a workflow after signature and idempotency checks.
@@ -525,34 +525,55 @@ Workflow durability and retry are related, but not identical. The engine checkpo
525
525
 
526
526
  ## Per-step retries
527
527
 
528
- Use `stepModule.retry(...)` when you want retry attempts to appear as separate step attempts in the dashboard:
528
+ Declare a `retry:` policy on `step({...})` and the framework **enforces** it — it compiles the policy to an Effect `Schedule` and retries `execute` for you. No hand-written retry needed:
529
529
 
530
530
  ```tsx
531
- import { workflow, step, stepModule } from '@voltro/workflow'
532
- import { Effect, Schedule, Schema } from 'effect'
531
+ import { workflow, step } from '@voltro/workflow'
532
+ import { Schema } from 'effect'
533
533
 
534
534
  class ProviderDown extends Schema.TaggedError<ProviderDown>()('ProviderDown', {
535
535
  message: Schema.String,
536
536
  }) {}
537
537
 
538
- const summary = yield* stepModule.retry(
539
- step({
540
- name: 'summarise-with-llm',
541
- input: { noteId },
542
- success: Schema.String,
543
- error: ProviderDown,
544
- retry: {
545
- strategy: 'exponential',
546
- maxAttempts: 5,
547
- baseDelay: '500 millis',
548
- },
549
- execute: callLlm(note.body),
550
- }),
551
- Schedule.exponential('500 millis').pipe(Schedule.recurs(4)),
552
- )
538
+ const summary = yield* step({
539
+ name: 'summarise-with-llm',
540
+ input: { noteId },
541
+ success: Schema.String,
542
+ error: ProviderDown,
543
+ retry: {
544
+ maxAttempts: 5,
545
+ strategy: 'exponential',
546
+ baseDelay: '500 millis',
547
+ },
548
+ execute: callLlm(note.body),
549
+ })
553
550
  ```
554
551
 
555
- The `retry` field on `step({...})` is metadata for inspection. The actual retry behaviour comes from `stepModule.retry(...)`, `Effect.retry(...)`, or another Effect retry combinator.
552
+ Retries run inside the one step and are transparent to the durable engine completed steps still checkpoint; the step's final outcome is recorded. `retry: { maxAttempts: 5 }` is already a good policy (exponential backoff, jittered).
553
+
554
+ ### The conditions you actually want
555
+
556
+ The useful retry question is rarely "how many times" — it's *which* failures, for *how long*, and *how spread out*. The policy covers all of it:
557
+
558
+ ```tsx
559
+ retry: {
560
+ maxAttempts: 5, // total attempts including the first (default 3)
561
+ strategy: 'exponential', // 'exponential' | 'fixed' | 'linear'
562
+ baseDelay: '500 millis',
563
+ maxDelay: '30 seconds', // ceiling so exponential growth can't run away
564
+ factor: 2, // exponential growth factor
565
+ jitter: true, // full jitter (default true) — anti-thundering-herd
566
+ maxElapsed: '5 minutes', // a total time BUDGET — stop retrying after this
567
+ retryableErrors: ['ProviderDown'], // retry ONLY these typed errors; others fail fast
568
+ respectRetryAfter: true, // honor a 429: retryAfter REPLACES this attempt's backoff
569
+ }
570
+ ```
571
+
572
+ - **`retryableErrors`** (or a `retryable: (error) => boolean` predicate) is the important one: retry the *transient* failures, fail *fast* on the permanent ones. A `ValidationError` should never be retried; a `ProviderDown` should.
573
+ - **`maxElapsed`** is a deadline across all attempts, not another count — the right bound when "keep trying for up to 5 minutes" matters more than "try 8 times".
574
+ - **`respectRetryAfter`** uses exactly the delay a provider asked for (a `retryAfterMillis` number, or `retryAfter` in seconds, on the thrown error) as the next delay, replacing the computed backoff for that attempt; falls back to backoff when there is no hint.
575
+
576
+ Retries here run *inside* the one step attempt, so the step is recorded as a SINGLE row in `_voltro_workflow_run_steps` with its final outcome — the individual in-step retries are not separate rows. `respectRetryAfter` uses the provider's delay AS the next delay (replacing the computed backoff for that attempt), falling back to backoff when the error carries no hint. For per-attempt rows in the dashboard, or a bespoke `Schedule`, use `stepModule.retry(step({...}), schedule)` instead. Do NOT set `retry:` on a step you ALSO wrap in `stepModule.retry` / `Effect.retry`: it would retry twice.
556
577
 
557
578
  ## What to retry
558
579
 
@@ -604,6 +625,64 @@ For ad-hoc ops, the same operation is available manually — the CLI (`voltro wo
604
625
  voltro workflows retry wfrun_01H...
605
626
  ```
606
627
 
628
+ `retry` starts a **fresh execution** — a new execution id, an empty journal, every step runs again. That is the right tool for a short, idempotent job, or when the input itself was wrong (`payloadOverride`). For a long multi-step pipeline where re-doing steps 1…N‑1 is expensive or unsafe, you want the opposite: resume from where it failed.
629
+
630
+ ## Resume from where it failed — `suspendOnFailure`
631
+
632
+ Declare `suspendOnFailure: true` on a workflow and a failure no longer becomes a terminal `failed` run — it **suspends** with the durable journal intact:
633
+
634
+ ```ts
635
+ export default workflow({
636
+ name: 'billing.close-month',
637
+ payload: { orgId: Schema.String },
638
+ success: Schema.Void,
639
+ error: Schema.Unknown,
640
+ idempotencyKey: ({ orgId }) => `billing.close-month:${orgId}`,
641
+ suspendOnFailure: true, // a failure suspends (recoverable), not fails (terminal)
642
+ execute: ({ orgId }) => Effect.gen(function* () {
643
+ yield* step('snapshot-ledger', /* … */) // completed steps are journaled
644
+ yield* step('call-tax-provider', /* … */) // ← a transient 503 here …
645
+ yield* step('finalise-invoices', /* … */)
646
+ }),
647
+ })
648
+ ```
649
+
650
+ When `call-tax-provider` fails, the run goes to `suspended` (not `failed`), carrying the failure reason. Fix the cause, then **resume** — the engine replays `snapshot-ledger` from the journal (it does **not** re-run) and continues from the failed step:
651
+
652
+ ```sh
653
+ voltro workflows resume wfrun_01H... # re-drives from the failure point
654
+ ```
655
+
656
+ ```ts
657
+ await ctx.workflows.resume(run.id) // the same, from a handler
658
+ ```
659
+
660
+ Because a suspended-on-failure run is **recoverable, not dead**, it shows up under `--status suspended`, NOT in the [dead-letter view](/docs/workflows/debugging) (`voltro workflows list --dead-letter`, which is failed-and-unhandled). Choose per workflow: `suspendOnFailure: true` for a long pipeline where prior work must not be redone; the default (`retry` from scratch) for short idempotent jobs.
661
+
662
+ ## Re-drive a failed run — `redrive`
663
+
664
+ `suspendOnFailure` is a decision you make **before** the run. What if a run already **failed** — it's sitting in the dead-letter view — and you still want to continue it from where it died, not re-run it from scratch? That is `redrive`:
665
+
666
+ ```sh
667
+ voltro workflows redrive wf_01H... # re-drive a FAILED run from the step it died on
668
+ ```
669
+
670
+ ```ts
671
+ await ctx.workflows.redrive(run.id) // the same, from a handler; addressed by run id
672
+ ```
673
+
674
+ `redrive` re-drives the run from its durable journal: every **completed** step replays from the journal (it does **not** re-run), and only the **failed** step(s) re-execute. It is the after-the-fact counterpart to `suspendOnFailure` + `resume` — same "continue from the failure point" outcome, but for a run that already went terminal without being marked suspend-on-failure. Fix the downstream cause first, then redrive.
675
+
676
+ The three recovery tools, and when each applies:
677
+
678
+ | Tool | Use when | Journal |
679
+ |---|---|---|
680
+ | `retry` | The input was wrong, or the job is short + idempotent | Fresh execution, empty journal — every step runs again |
681
+ | `resume` | The run is `suspended` (you set `suspendOnFailure: true`, or it awaits a signal) | Continues from the failure/suspend point — completed steps replay |
682
+ | `redrive` | The run is `failed` (dead-letter) and re-running earlier steps is expensive/unsafe | Continues from the failure point — completed steps replay, failed steps re-run |
683
+
684
+ `redrive` refuses a run that isn't a not-yet-discarded failure (use `resume` for a suspended run, `retry` for a fresh execution), and it needs a durable journal — on the memory store there is nothing to re-drive, so it declines cleanly (`redriven: false` with a reason) rather than pretending. It works under `voltro serve` as well as `voltro dev`, because dead-letter recovery happens in production. A re-drive records a `run-redriven` event on the run's timeline.
685
+
607
686
  ## Compensation
608
687
 
609
688
  For saga-style workflows, model compensation explicitly with `Effect.catchAll` around the step that can fail after an earlier side effect:
@@ -664,7 +743,7 @@ The dashboard and `voltro workflows show <runId>` read these same tables through
664
743
 
665
744
  - **Catching errors and returning success.** Operators lose the failed run.
666
745
  - **Retrying non-idempotent side effects.** Double charges and duplicate emails are workflow bugs, not retry bugs.
667
- - **Putting retry metadata on a step without `Effect.retry` or `stepModule.retry`.** Metadata only explains intent; it does not execute a policy.
746
+ - **Declaring `retry:` on a step you ALSO wrap in `Effect.retry` / `stepModule.retry`.** `retry:` is enforced now the step would retry twice. Keep one.
668
747
  - **Expecting a DLQ table.** The current triage surface is failed runs plus retry controls.
669
748
 
670
749
 
@@ -1181,12 +1260,22 @@ Voltro records workflow runs, step attempts, and lifecycle events into framework
1181
1260
  | `_voltro_workflow_start_contexts` | One row per started execution id: starter subject, trace id, source, parent execution id, parent-close policy, and creation time. Used for cross-runner context handoff. |
1182
1261
  | `_voltro_workflow_runs` | One row per run: `id`, `tag`, `executionId`, `status`, `payload`, `workflowVersion`, `workflowPatches`, `output`, error fields, subject, start source, timing, trace id, parent execution id, parent-close policy. |
1183
1262
  | `_voltro_workflow_run_steps` | One row per step attempt: step name, attempt number, recorded input, retry metadata, output or error, duration. |
1184
- | `_voltro_workflow_run_events` | Lifecycle events: `run-started`, `run-succeeded`, `run-failed`, `run-suspended`, `run-resumed`, `run-cancelled`, `timer-set`, `timer-fired`, `signal-awaited`, `signal-sent`, `signal-received`, `update-requested`, `update-received`, `update-completed`, `update-failed`. |
1185
- | `_voltro_workflow_events` | Domain events emitted through `ctx.events.emit(...)`: event id, name, payload, source, subject, trace id, occurred time. |
1263
+ | `_voltro_workflow_run_events` | Lifecycle events: `run-started`, `run-succeeded`, `run-failed`, `run-suspended`, `run-resumed`, `run-redriven`, `run-cancelled`, `timer-set`, `timer-fired`, `signal-awaited`, `signal-sent`, `signal-received`, `update-requested`, `update-received`, `update-completed`, `update-failed`. |
1264
+ | `_voltro_workflow_events` | Domain events emitted through `ctx.events.publish(...)`: event id, name, payload, source, subject, trace id, occurred time. |
1186
1265
  | `_voltro_workflow_event_deliveries` | One row per workflow trigger delivery: event id, trigger id, workflow name, execution id, idempotency key, status, error. |
1187
1266
 
1188
1267
  Run status is `running`, `succeeded`, `failed`, `cancelled`, or `suspended`.
1189
1268
 
1269
+ ## Dead-letter: triaging failed runs
1270
+
1271
+ The framework does not retry a workflow on its own (see [Retries](/docs/workflows/retries) — you compose retries inside `execute:` with `Effect.retry`). So a run that reaches `failed` is **terminal**: it is the dead-letter. `voltro workflows list --dead-letter` (or the Prometheus series `voltro_workflow_runs_total{status="failed"}` and the stale `voltro_workflow_last_success_timestamp_seconds` gauge — see [Prometheus](/docs/plugins/prometheus)) is your queue of unhandled failures.
1272
+
1273
+ Triage a dead-letter run one of three ways:
1274
+
1275
+ - **Retry** it — `voltro workflows retry <id>` starts a fresh execution against the original (or an overridden) payload once you've fixed the cause. Every step runs again from scratch.
1276
+ - **Re-drive** it — `voltro workflows redrive <id>` re-drives the run from its durable journal: completed steps replay, only the failed step(s) re-execute. Use this instead of `retry` for a long pipeline where redoing steps 1…N‑1 is expensive or unsafe. It is the after-the-fact counterpart to [`suspendOnFailure` + `resume`](/docs/workflows/retries) and works under `voltro serve` too. Refuses a non-failed / already-discarded run; declines cleanly when there is no durable journal (the memory store). Records a `run-redriven` event.
1277
+ - **Discard** it — `voltro workflows discard <id>` acknowledges the failure so it drops off the `--dead-letter` view. It is an **ack, not a re-classification**: the run stays `status: 'failed'` (the outcome + audit trail survive) and gains a `discardedAt` timestamp. `--status failed` still lists it, marked `discarded`; only `--dead-letter` hides it. Discarding a non-failed run is refused, and discarding is idempotent.
1278
+
1190
1279
  ## Dashboard
1191
1280
 
1192
1281
  When `voltro dev` is running, the Workflows panel lists recent runs, their status, start source, timing, payload, output/error, step attempts, and events. The run detail view is the fastest way to answer:
@@ -1199,7 +1288,7 @@ When `voltro dev` is running, the Workflows panel lists recent runs, their statu
1199
1288
  - What output or typed error did it produce?
1200
1289
  - Did a timer or signal fire?
1201
1290
 
1202
- The panel also exposes run controls for users with the right capability: cancel, retry, suspend, resume, send signal, and send a tracked update.
1291
+ The panel also exposes run controls for users with the right capability: cancel, retry, suspend, resume, redrive (re-drive a failed run from its journal), discard (acknowledge a failed run), send signal, and send a tracked update.
1203
1292
 
1204
1293
  Run filters can be saved as named views. The selected view and ad-hoc
1205
1294
  filters are mirrored into the URL query string, so a teammate can open the
@@ -1213,12 +1302,15 @@ queued/running/waiting work by workflow lane and start source.
1213
1302
  ```sh
1214
1303
  voltro workflows list --status running --tail 50
1215
1304
  voltro workflows list --tag notes.summarise --format json
1305
+ voltro workflows list --dead-letter # failed runs not yet discarded
1216
1306
  voltro workflows start notes.summarise --payload '{"noteId":"note_123"}'
1217
1307
  voltro workflows show wfrun_01H...
1218
1308
  voltro workflows retry wfrun_01H...
1219
1309
  voltro workflows cancel wfrun_01H...
1220
1310
  voltro workflows suspend wfrun_01H...
1221
1311
  voltro workflows resume wfrun_01H...
1312
+ voltro workflows redrive wfrun_01H... # re-drive a failed run from where it died
1313
+ voltro workflows discard wfrun_01H... # acknowledge a failed run
1222
1314
  voltro workflows signal wfrun_01H... --name approval --payload '{"approved":true}'
1223
1315
  voltro workflows update wfrun_01H... --name approve --payload '{"decision":true}'
1224
1316
  voltro workflows children exec_01H...
@@ -1241,6 +1333,8 @@ POST /_voltro/inspect/workflows/runs/:id/cancel
1241
1333
  POST /_voltro/inspect/workflows/runs/:id/retry
1242
1334
  POST /_voltro/inspect/workflows/runs/:id/suspend
1243
1335
  POST /_voltro/inspect/workflows/runs/:id/resume
1336
+ POST /_voltro/inspect/workflows/runs/:id/redrive
1337
+ POST /_voltro/inspect/workflows/runs/:id/discard
1244
1338
  POST /_voltro/inspect/workflows/runs/:id/signal
1245
1339
  POST /_voltro/inspect/workflows/runs/:id/update
1246
1340
  ```
@@ -1383,16 +1477,23 @@ Validation runs on the **decoded** value, so a `Schema.Date` payload accepts a
1383
1477
  <!-- source: en/workflows/event-triggers.md -->
1384
1478
  ## Event triggers
1385
1479
 
1386
- _defineEventTrigger — bind a domain event name to a workflow. ctx.events.emit(name, data) fans the event out to every matching trigger with filter, payload-mapping, and idempotency._
1480
+ _defineEventTrigger — bind a domain event name to a workflow. ctx.events.publish(descriptor, key, payload) fans the event out to every matching trigger with filter, payload-mapping, and idempotency._
1387
1481
 
1388
- A workflow is usually kicked off directly (`ctx.workflows.start(...)`). An **event trigger** decouples that: a `*.trigger.tsx` file binds a domain **event name** to a **workflow name**, and any handler that calls `ctx.events.emit('event.name', data)` fans the event out to every trigger listening for it. The emitter never names the workflow — "something happened" is separated from "run this workflow", so you add reactions without touching the code that emits.
1482
+ A workflow is usually kicked off directly (`ctx.workflows.start(...)`). An **event trigger** decouples that: a `*.trigger.tsx` file binds a **declared event** to a **workflow name**, and any handler that publishes that event fans it out to every trigger listening for it. The emitter never names the workflow — "something happened" is separated from "run this workflow", so you add reactions without touching the code that emits.
1389
1483
 
1390
1484
  ```tsx
1485
+ // events/orders.event.ts — the declaration both sides share
1486
+ export const orderPaid = defineEvent({
1487
+ name: 'order.paid',
1488
+ key: Schema.Struct({ orderId: Schema.String }),
1489
+ payload: Schema.Struct({ total: Schema.Number, tenantId: Schema.String }),
1490
+ })
1491
+
1391
1492
  // triggers/orderPaid.trigger.tsx
1392
1493
  import { defineEventTrigger } from '@voltro/runtime'
1393
1494
 
1394
1495
  export default defineEventTrigger({
1395
- event: 'order.paid', // the event name to listen for
1496
+ on: orderPaid, // the declared event to listen for
1396
1497
  workflow: 'fulfilment.run', // the workflow to start
1397
1498
  // Optional: skip when the predicate returns false.
1398
1499
  filter: (e) => e.data.total > 0,
@@ -1407,23 +1508,21 @@ Discovery walks every `*.trigger.tsx`; the `default` export must be a `defineEve
1407
1508
 
1408
1509
  ## Emitting an event
1409
1510
 
1410
- From any handler, emit through `ctx.events`:
1511
+ From any handler with a `ctx`, publish the declared event:
1411
1512
 
1412
1513
  ```ts
1413
- const execute = async (input: { orderId: string }, ctx: AppContext) => {
1514
+ const execute = (input: { orderId: string }, ctx: AppContext) => Effect.gen(function* () {
1414
1515
  // ... mark the order paid ...
1415
- const result = await ctx.events.emit('order.paid', {
1416
- orderId: input.orderId,
1417
- tenantId: ctx.request.subject.tenantId,
1516
+ yield* ctx.events.publish(orderPaid, { orderId: input.orderId }, {
1418
1517
  total: order.total,
1518
+ tenantId: ctx.request.subject.tenantId,
1419
1519
  })
1420
- // result.eventId — the persisted event id
1421
- // result.triggered — one entry per matched trigger:
1422
- // { triggerId, workflowName, status: 'started' | 'skipped' | 'failed', run?, reason? }
1423
- }
1520
+ })
1424
1521
  ```
1425
1522
 
1426
- `emit(name, data, options?)` accepts `{ id?, source? }` (override the event id / source label). It returns `{ eventId, triggered }` as soon as the workflows are kicked off the workflows themselves run durably in the background, exactly like `ctx.workflows.start`.
1523
+ `publish` reaches BOTH audiences from one call: the durable one (this page — the event log plus every matching trigger) and the ephemeral one ([connected clients](/docs/data/events)). Inside a mutation both fire on COMMIT and neither on rollback, so a client and a triggered workflow cannot disagree about whether the thing happened.
1524
+
1525
+ `on:` reads the event NAME off the descriptor, so renaming the event moves the trigger with it. The older string form (`event: 'order.paid'`) was removed in 0.25.0 — with a string, a rename left the trigger matching nothing and the workflow simply never ran again, without an error.
1427
1526
 
1428
1527
  The event envelope every `filter` / `payload` / `idempotencyKey` receives is:
1429
1528
 
@@ -1439,11 +1538,30 @@ The event envelope every `filter` / `payload` / `idempotencyKey` receives is:
1439
1538
  - **Idempotency.** Before starting, the runtime checks for an existing delivery with the same `idempotencyKey` (default `<eventId>:<triggerId>`) — a duplicate is recorded as `status: 'skipped', reason: 'duplicate'` and the workflow is not started twice.
1440
1539
  - **Every emit is audited.** The event itself lands in `_voltro_workflow_events`; one row per trigger lands in `_voltro_workflow_event_deliveries` with `status` (`starting → started`, or `skipped` / `failed`), the started `executionId`, and any error message. Both tables are reactive — the dashboard's Workflows view surfaces deliveries live.
1441
1540
 
1541
+ ## Retention — and why the delivery TTL is not just housekeeping
1542
+
1543
+ Both audit tables are append-only, so both are swept on a **30-day** default by the
1544
+ boot retention GC (postgres): `VOLTRO_WORKFLOW_EVENTS_TTL_HOURS` and
1545
+ `VOLTRO_WORKFLOW_EVENT_DELIVERIES_TTL_HOURS`. The delivery log grows faster — one
1546
+ row per *trigger*, so three triggers on one event write four rows per emit.
1547
+
1548
+ **The delivery TTL is the deduplication window.** The idempotency check above looks
1549
+ for an existing delivery row, so once a row is swept its key is no longer
1550
+ deduplicated. With the default key (`<eventId>:<triggerId>`, and `eventId` is fresh
1551
+ per emit) a duplicate cannot occur and this costs nothing. It matters only when you
1552
+ supply your own `idempotencyKey`: if your app can re-emit the *same* stable key
1553
+ (`fulfil:order-123`) more than 30 days apart and must still be deduplicated, raise
1554
+ `VOLTRO_WORKFLOW_EVENT_DELIVERIES_TTL_HOURS` past that horizon.
1555
+
1556
+ Unlike `_voltro_outbox`, the delivery log is **not** status-filtered — a stale
1557
+ `starting` row has no requeue path and no reader, so keeping it would leave the
1558
+ table unbounded for exactly the rows a crash produces.
1559
+
1442
1560
  ## When to use it
1443
1561
 
1444
1562
  Reach for an event trigger when ONE thing happening should fan out to several independent reactions, or when you want the emitter to stay ignorant of the consumers:
1445
1563
 
1446
- - `order.paid` → start fulfilment AND a receipt-email workflow AND an analytics rollup — three triggers, one `emit`.
1447
- - A mutation emits `user.signedUp`; an onboarding workflow trigger starts the drip sequence. The signup mutation never imports the onboarding workflow.
1564
+ - `order.paid` → start fulfilment AND a receipt-email workflow AND an analytics rollup — three triggers, one `publish`.
1565
+ - A mutation publishes `user.signedUp`; an onboarding workflow trigger starts the drip sequence. The signup mutation never imports the onboarding workflow.
1448
1566
 
1449
- If the handler already knows exactly which workflow to run and there's only one, call [`ctx.workflows.start(...)`](/docs/workflows/definition) directly — the trigger indirection only pays off when you're decoupling emit from react, or fanning one event out to many.
1567
+ If the handler already knows exactly which workflow to run and there's only one, call [`ctx.workflows.start(...)`](/docs/workflows/definition) directly — the trigger indirection only pays off when you're decoupling publish from react, or fanning one event out to many.
@@ -12,16 +12,16 @@
12
12
  "dependencies": {
13
13
  "@effect/platform": "^0.97.0",
14
14
  "@effect/rpc": "^0.76.0",
15
- "@voltro/ai": "0.24.0",
16
- "@voltro/cli": "0.24.0",
17
- "@voltro/database": "0.24.0",
18
- "@voltro/env": "0.24.0",
19
- "@voltro/protocol": "0.24.0",
20
- "@voltro/runtime": "0.24.0",
15
+ "@voltro/ai": "0.26.0",
16
+ "@voltro/cli": "0.26.0",
17
+ "@voltro/database": "0.26.0",
18
+ "@voltro/env": "0.26.0",
19
+ "@voltro/protocol": "0.26.0",
20
+ "@voltro/runtime": "0.26.0",
21
21
  "effect": "^3.22.0"
22
22
  },
23
23
  "devDependencies": {
24
- "@voltro/testing": "0.24.0",
24
+ "@voltro/testing": "0.26.0",
25
25
  "typescript": "^6.0.3",
26
26
  "vitest": "^4.1.10"
27
27
  }
@@ -13,17 +13,17 @@
13
13
  "dependencies": {
14
14
  "@effect/platform": "^0.97.0",
15
15
  "@effect/rpc": "^0.76.0",
16
- "@voltro/cli": "0.24.0",
17
- "@voltro/database": "0.24.0",
18
- "@voltro/env": "0.24.0",
19
- "@voltro/plugin-auth": "0.24.0",
20
- "@voltro/protocol": "0.24.0",
21
- "@voltro/runtime": "0.24.0",
22
- "@voltro/sql-postgres": "0.24.0",
16
+ "@voltro/cli": "0.26.0",
17
+ "@voltro/database": "0.26.0",
18
+ "@voltro/env": "0.26.0",
19
+ "@voltro/plugin-auth": "0.26.0",
20
+ "@voltro/protocol": "0.26.0",
21
+ "@voltro/runtime": "0.26.0",
22
+ "@voltro/sql-postgres": "0.26.0",
23
23
  "effect": "^3.22.0"
24
24
  },
25
25
  "devDependencies": {
26
- "@voltro/testing": "0.24.0",
26
+ "@voltro/testing": "0.26.0",
27
27
  "typescript": "^6.0.3",
28
28
  "vitest": "^4.1.10"
29
29
  }
@@ -13,16 +13,16 @@
13
13
  "dependencies": {
14
14
  "@effect/platform": "^0.97.0",
15
15
  "@effect/rpc": "^0.76.0",
16
- "@voltro/cli": "0.24.0",
17
- "@voltro/database": "0.24.0",
18
- "@voltro/env": "0.24.0",
19
- "@voltro/plugin-multitenancy": "0.24.0",
20
- "@voltro/protocol": "0.24.0",
21
- "@voltro/runtime": "0.24.0",
16
+ "@voltro/cli": "0.26.0",
17
+ "@voltro/database": "0.26.0",
18
+ "@voltro/env": "0.26.0",
19
+ "@voltro/plugin-multitenancy": "0.26.0",
20
+ "@voltro/protocol": "0.26.0",
21
+ "@voltro/runtime": "0.26.0",
22
22
  "effect": "^3.22.0"
23
23
  },
24
24
  "devDependencies": {
25
- "@voltro/testing": "0.24.0",
25
+ "@voltro/testing": "0.26.0",
26
26
  "typescript": "^6.0.3",
27
27
  "vitest": "^4.1.10"
28
28
  }
@@ -13,16 +13,16 @@
13
13
  "dependencies": {
14
14
  "@effect/platform": "^0.97.0",
15
15
  "@effect/rpc": "^0.76.0",
16
- "@voltro/cli": "0.24.0",
17
- "@voltro/database": "0.24.0",
18
- "@voltro/env": "0.24.0",
19
- "@voltro/plugin-deactivation": "0.24.0",
20
- "@voltro/protocol": "0.24.0",
21
- "@voltro/runtime": "0.24.0",
16
+ "@voltro/cli": "0.26.0",
17
+ "@voltro/database": "0.26.0",
18
+ "@voltro/env": "0.26.0",
19
+ "@voltro/plugin-deactivation": "0.26.0",
20
+ "@voltro/protocol": "0.26.0",
21
+ "@voltro/runtime": "0.26.0",
22
22
  "effect": "^3.22.0"
23
23
  },
24
24
  "devDependencies": {
25
- "@voltro/testing": "0.24.0",
25
+ "@voltro/testing": "0.26.0",
26
26
  "typescript": "^6.0.3",
27
27
  "vitest": "^4.1.10"
28
28
  }
@@ -13,18 +13,18 @@
13
13
  "dependencies": {
14
14
  "@react-email/components": "^1.0.12",
15
15
  "@react-email/render": "^1.4.0",
16
- "@voltro/cli": "0.24.0",
17
- "@voltro/database": "0.24.0",
18
- "@voltro/env": "0.24.0",
19
- "@voltro/plugin-mail": "0.24.0",
20
- "@voltro/plugin-multitenancy": "0.24.0",
21
- "@voltro/protocol": "0.24.0",
22
- "@voltro/runtime": "0.24.0",
16
+ "@voltro/cli": "0.26.0",
17
+ "@voltro/database": "0.26.0",
18
+ "@voltro/env": "0.26.0",
19
+ "@voltro/plugin-mail": "0.26.0",
20
+ "@voltro/plugin-multitenancy": "0.26.0",
21
+ "@voltro/protocol": "0.26.0",
22
+ "@voltro/runtime": "0.26.0",
23
23
  "effect": "^3.22.0",
24
24
  "react": "^19.0.0"
25
25
  },
26
26
  "devDependencies": {
27
- "@voltro/testing": "0.24.0",
27
+ "@voltro/testing": "0.26.0",
28
28
  "typescript": "^6.0.3",
29
29
  "vitest": "^4.1.10"
30
30
  }
@@ -13,18 +13,18 @@
13
13
  "dependencies": {
14
14
  "@effect/platform": "^0.97.0",
15
15
  "@effect/rpc": "^0.76.0",
16
- "@voltro/cli": "0.24.0",
17
- "@voltro/database": "0.24.0",
18
- "@voltro/env": "0.24.0",
19
- "@voltro/plugin-multitenancy": "0.24.0",
20
- "@voltro/plugin-storage": "0.24.0",
21
- "@voltro/protocol": "0.24.0",
22
- "@voltro/runtime": "0.24.0",
23
- "@voltro/sql-mysql": "0.24.0",
16
+ "@voltro/cli": "0.26.0",
17
+ "@voltro/database": "0.26.0",
18
+ "@voltro/env": "0.26.0",
19
+ "@voltro/plugin-multitenancy": "0.26.0",
20
+ "@voltro/plugin-storage": "0.26.0",
21
+ "@voltro/protocol": "0.26.0",
22
+ "@voltro/runtime": "0.26.0",
23
+ "@voltro/sql-mysql": "0.26.0",
24
24
  "effect": "^3.22.0"
25
25
  },
26
26
  "devDependencies": {
27
- "@voltro/testing": "0.24.0",
27
+ "@voltro/testing": "0.26.0",
28
28
  "typescript": "^6.0.3",
29
29
  "vitest": "^4.1.10"
30
30
  }
@@ -11,17 +11,17 @@
11
11
  "test": "voltro test"
12
12
  },
13
13
  "dependencies": {
14
- "@voltro/cli": "0.24.0",
15
- "@voltro/database": "0.24.0",
16
- "@voltro/env": "0.24.0",
17
- "@voltro/plugin-multitenancy": "0.24.0",
18
- "@voltro/plugin-storage": "0.24.0",
19
- "@voltro/protocol": "0.24.0",
20
- "@voltro/runtime": "0.24.0",
14
+ "@voltro/cli": "0.26.0",
15
+ "@voltro/database": "0.26.0",
16
+ "@voltro/env": "0.26.0",
17
+ "@voltro/plugin-multitenancy": "0.26.0",
18
+ "@voltro/plugin-storage": "0.26.0",
19
+ "@voltro/protocol": "0.26.0",
20
+ "@voltro/runtime": "0.26.0",
21
21
  "effect": "^3.22.0"
22
22
  },
23
23
  "devDependencies": {
24
- "@voltro/testing": "0.24.0",
24
+ "@voltro/testing": "0.26.0",
25
25
  "typescript": "^6.0.3",
26
26
  "vitest": "^4.1.10"
27
27
  }
@@ -12,17 +12,17 @@
12
12
  "dependencies": {
13
13
  "@effect/platform": "^0.97.0",
14
14
  "@effect/rpc": "^0.76.0",
15
- "@voltro/cli": "0.24.0",
16
- "@voltro/database": "0.24.0",
17
- "@voltro/env": "0.24.0",
18
- "@voltro/plugin-governance": "0.24.0",
19
- "@voltro/plugin-multitenancy": "0.24.0",
20
- "@voltro/protocol": "0.24.0",
21
- "@voltro/runtime": "0.24.0",
15
+ "@voltro/cli": "0.26.0",
16
+ "@voltro/database": "0.26.0",
17
+ "@voltro/env": "0.26.0",
18
+ "@voltro/plugin-governance": "0.26.0",
19
+ "@voltro/plugin-multitenancy": "0.26.0",
20
+ "@voltro/protocol": "0.26.0",
21
+ "@voltro/runtime": "0.26.0",
22
22
  "effect": "^3.22.0"
23
23
  },
24
24
  "devDependencies": {
25
- "@voltro/testing": "0.24.0",
25
+ "@voltro/testing": "0.26.0",
26
26
  "typescript": "^6.0.3",
27
27
  "vitest": "^4.1.10"
28
28
  }
@@ -28,7 +28,8 @@ state + multi-instance cluster workflow runners.
28
28
  |---|---|---|
29
29
  | `app.config.ts` | app config | `store: 'memory'`, zero-infra boot. |
30
30
  | `database/schema.ts` | schema | core `actors`/`tenants` + an `orders` table (`tenant()` + `.reactive()`). |
31
- | `mutations/orders.place.mutation.*` | mutation | insert an order, then **emit `order.placed`** post-commit via `ctx.events.emit`. Tenant write-guard. |
31
+ | `mutations/orders.place.mutation.*` | mutation | insert an order and **publish `order.placed`** on commit via `ctx.events.publish`. Tenant write-guard. |
32
+ | `events/orders.event.ts` | event | the declared `order.placed` — one value shared by the publisher, the trigger and any client. |
32
33
  | `triggers/order.placed.trigger.tsx` | event trigger | maps event `order.placed` → **start** the `orders.fulfill` workflow. |
33
34
  | `workflows/order.fulfill.workflow.*` | durable workflow | `step('reserve-stock')` (with **`withCompensation`** saga rollback) → `sleep` → **`awaitSignal('approval')`** human gate → `step('ship')` / mark rejected. |
34
35
  | `mutations/orders.approve.mutation.*` | mutation | inject the `approval` signal into the parked run via `ctx.workflows.signal(...)`. |
@@ -0,0 +1,18 @@
1
+ // The declared domain event. Browser-safe: the SAME value is imported by the
2
+ // mutation that publishes it, the trigger that reacts to it, and any client that
3
+ // wants to watch it live — which is what makes the payload type identical
4
+ // everywhere and a rename impossible to half-apply.
5
+
6
+ import { defineEvent } from '@voltro/protocol'
7
+ import { Schema } from 'effect'
8
+
9
+ export const orderPlaced = defineEvent({
10
+ name: 'order.placed',
11
+ // The ROUTING key: which subscribers receive this. Only what decides
12
+ // delivery belongs here — the tenant comes from the subject, never the key.
13
+ key: Schema.Struct({ orderId: Schema.String }),
14
+ payload: Schema.Struct({
15
+ orderId: Schema.String,
16
+ tenantId: Schema.String,
17
+ }),
18
+ })