@voltro/cli 0.29.0 → 0.30.1

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 (169) hide show
  1. package/CHANGELOG.md +448 -0
  2. package/THIRD-PARTY-NOTICES.md +2 -2
  3. package/dist/apiBuild-Ca6Sq5jy.js +2 -0
  4. package/dist/{apiBuild-BESZbTjA.js → apiBuild-DmkzRyv7.js} +2 -2
  5. package/dist/bin.js +3 -3
  6. package/dist/{checkCommand-BPAtyWs8.js → checkCommand-C4konUy9.js} +435 -304
  7. package/dist/checkCommand-DHD0aBeR.js +2 -0
  8. package/dist/{commands-82BDEktg.js → commands-BkJft9K2.js} +2413 -1814
  9. package/dist/{dbCommand-bCBbk6tz.js → dbCommand-qn__kLEt.js} +344 -265
  10. package/dist/dbCommand-wfGi3FgG.js +2 -0
  11. package/dist/{dev-Cg4BKLmi.js → dev-C_NPcLDp.js} +2716 -1782
  12. package/dist/{dev-DeEJj5T4.js → dev-DXhhZ7-f.js} +1 -1
  13. package/dist/frameworkTableAssembly-Bn_c4Mnl.js +2 -0
  14. package/dist/{frameworkTableAssembly-4Db40V23.js → frameworkTableAssembly-Cpgn2e88.js} +155 -141
  15. package/dist/index.d.ts +7 -2
  16. package/dist/index.js +1 -1
  17. package/dist/{inspect-BMvQpwqy.js → inspect-BUUjt773.js} +150 -14
  18. package/dist/inspect-mmBuRXmy.js +2 -0
  19. package/dist/{inspectMetrics-DHYygE4s.js → inspectMetrics-CkB2J0Zn.js} +566 -512
  20. package/dist/{manifestBuild-C4R1EdHn.js → manifestBuild-BLrVuSlM.js} +1 -1
  21. package/dist/manifestBuild-Dj8Jjoto.js +2 -0
  22. package/dist/{sdkgen-CW5NbYF0.js → sdkgen-wsWLrq4l.js} +1 -1
  23. package/dist/serveCommand-HtI4X5kj.js +1577 -0
  24. package/dist/serveEntry.js +2 -2
  25. package/dist/{start-CsCIaW4-.js → start-egkbpugc.js} +177 -175
  26. package/dist/startEntry.js +2 -2
  27. package/package.json +17 -17
  28. package/templates/AGENTS.core.md +50 -0
  29. package/templates/AGENTS.md +53 -2
  30. package/templates/agent-docs/_index.md +3 -2
  31. package/templates/agent-docs/_manifest.json +16 -4
  32. package/templates/agent-docs/ai.md +56 -0
  33. package/templates/agent-docs/cli.md +46 -0
  34. package/templates/agent-docs/internationalization.md +8 -7
  35. package/templates/agent-docs/local-first-mobile.md +18 -6
  36. package/templates/agent-docs/routing.md +15 -1
  37. package/templates/agent-docs/security.md +17 -0
  38. package/templates/agent-docs/templates/apibackends.md +209 -0
  39. package/templates/agent-docs/templates/appshells.md +144 -1
  40. package/templates/agent-docs/templates/mobile.md +60 -0
  41. package/templates/agent-docs/templates/overview.md +17 -7
  42. package/templates/agent-docs/whats-new.md +36 -243
  43. package/templates/agent-docs/workflows.md +367 -0
  44. package/templates/apps/api-ai/package.json +7 -7
  45. package/templates/apps/api-auth/package.json +8 -8
  46. package/templates/apps/api-backend/package.json +7 -7
  47. package/templates/apps/api-backend-deactivation/package.json +7 -7
  48. package/templates/apps/api-backend-mail/package.json +8 -8
  49. package/templates/apps/api-backend-mariadb/package.json +9 -9
  50. package/templates/apps/api-backend-sqlite/package.json +8 -8
  51. package/templates/apps/api-backend-storage/package.json +8 -8
  52. package/templates/apps/api-cms/package.json +10 -10
  53. package/templates/apps/api-collab/README.md +70 -0
  54. package/templates/apps/api-collab/app.config.ts +28 -0
  55. package/templates/apps/api-collab/database/schema.ts +63 -0
  56. package/templates/apps/api-collab/mutations/documents.create.mutation.server.ts +19 -0
  57. package/templates/apps/api-collab/mutations/documents.create.mutation.ts +36 -0
  58. package/templates/apps/api-collab/mutations/documents.setBody.mutation.server.ts +20 -0
  59. package/templates/apps/api-collab/mutations/documents.setBody.mutation.ts +30 -0
  60. package/templates/apps/api-collab/package.json +30 -0
  61. package/templates/apps/api-collab/queries/documents.query.server.ts +16 -0
  62. package/templates/apps/api-collab/queries/documents.query.ts +23 -0
  63. package/templates/apps/api-collab/template.json +6 -0
  64. package/templates/apps/api-collab/tests/documents.setBody.test.ts +89 -0
  65. package/templates/apps/api-collab/tsconfig.json +5 -0
  66. package/templates/apps/api-data-advanced/package.json +8 -8
  67. package/templates/apps/api-durable/package.json +8 -8
  68. package/templates/apps/api-feature-flags/package.json +9 -9
  69. package/templates/apps/api-governance/package.json +8 -8
  70. package/templates/apps/api-kv/package.json +8 -8
  71. package/templates/apps/api-moderation/package.json +8 -8
  72. package/templates/apps/api-observability/package.json +8 -8
  73. package/templates/apps/api-ratelimit/package.json +8 -8
  74. package/templates/apps/api-rbac/package.json +8 -8
  75. package/templates/apps/api-rest/package.json +7 -7
  76. package/templates/apps/api-saas/package.json +11 -11
  77. package/templates/apps/api-saas-starter/package.json +10 -10
  78. package/templates/apps/api-search/package.json +8 -8
  79. package/templates/apps/api-status/package.json +8 -8
  80. package/templates/apps/api-versioning/package.json +8 -8
  81. package/templates/apps/api-webhooks/package.json +9 -9
  82. package/templates/apps/changelog/package.json +6 -6
  83. package/templates/apps/edge-functions/package.json +2 -2
  84. package/templates/apps/frontend-admin/app.config.ts +1 -1
  85. package/templates/apps/frontend-admin/package.json +8 -8
  86. package/templates/apps/frontend-admin/src/locales/index.ts +1 -1
  87. package/templates/apps/frontend-app/app.config.ts +1 -1
  88. package/templates/apps/frontend-app/package.json +8 -8
  89. package/templates/apps/frontend-app/src/locales/index.ts +1 -1
  90. package/templates/apps/frontend-app/src/pages/layout.tsx +1 -1
  91. package/templates/apps/frontend-app/src/pages/page.tsx +1 -1
  92. package/templates/apps/frontend-auth/package.json +8 -8
  93. package/templates/apps/frontend-auth/src/locales/index.ts +1 -1
  94. package/templates/apps/frontend-blank/app.config.ts +1 -1
  95. package/templates/apps/frontend-blank/package.json +7 -7
  96. package/templates/apps/frontend-blank/src/locales/index.ts +1 -1
  97. package/templates/apps/frontend-blank/src/pages/layout.tsx +1 -1
  98. package/templates/apps/frontend-blank/src/pages/page.tsx +1 -1
  99. package/templates/apps/frontend-cms/package.json +9 -9
  100. package/templates/apps/frontend-cms/src/locales/index.ts +1 -1
  101. package/templates/apps/frontend-collab/README.md +80 -0
  102. package/templates/apps/frontend-collab/app.config.ts +38 -0
  103. package/templates/apps/frontend-collab/package.json +33 -0
  104. package/templates/apps/frontend-collab/src/locales/de.ts +29 -0
  105. package/templates/apps/frontend-collab/src/locales/en.ts +31 -0
  106. package/templates/apps/frontend-collab/src/locales/index.ts +15 -0
  107. package/templates/apps/frontend-collab/src/pages/layout.tsx +39 -0
  108. package/templates/apps/frontend-collab/src/pages/page.test.tsx +150 -0
  109. package/templates/apps/frontend-collab/src/pages/page.tsx +186 -0
  110. package/templates/apps/frontend-collab/template.json +6 -0
  111. package/templates/apps/frontend-collab/tsconfig.json +11 -0
  112. package/templates/apps/frontend-contact/package.json +7 -7
  113. package/templates/apps/frontend-dashboard/app.config.ts +1 -1
  114. package/templates/apps/frontend-dashboard/package.json +7 -7
  115. package/templates/apps/frontend-dashboard/src/locales/index.ts +1 -1
  116. package/templates/apps/frontend-docs/package.json +7 -7
  117. package/templates/apps/frontend-i18n/package.json +6 -6
  118. package/templates/apps/frontend-landing/package.json +7 -7
  119. package/templates/apps/frontend-portal/package.json +8 -8
  120. package/templates/apps/frontend-portal/src/locales/index.ts +1 -1
  121. package/templates/apps/frontend-saas/README.md +1 -1
  122. package/templates/apps/frontend-saas/app.config.ts +1 -1
  123. package/templates/apps/frontend-saas/package.json +8 -8
  124. package/templates/apps/frontend-saas/src/locales/index.ts +1 -1
  125. package/templates/apps/frontend-spa/app.config.ts +1 -1
  126. package/templates/apps/frontend-spa/package.json +7 -7
  127. package/templates/apps/frontend-spa/src/locales/index.ts +1 -1
  128. package/templates/apps/frontend-spa/src/pages/layout.tsx +1 -1
  129. package/templates/apps/frontend-spa/src/pages/page.tsx +1 -1
  130. package/templates/apps/frontend-ssr/app.config.ts +1 -1
  131. package/templates/apps/frontend-ssr/package.json +7 -7
  132. package/templates/apps/frontend-ssr/src/locales/index.ts +1 -1
  133. package/templates/apps/frontend-ssr/src/pages/layout.tsx +1 -1
  134. package/templates/apps/frontend-ssr/src/pages/page.tsx +1 -1
  135. package/templates/apps/frontend-ssr-api/app.config.ts +1 -1
  136. package/templates/apps/frontend-ssr-api/package.json +8 -8
  137. package/templates/apps/frontend-ssr-api/src/locales/index.ts +1 -1
  138. package/templates/apps/frontend-ssr-api/src/pages/layout.tsx +1 -1
  139. package/templates/apps/frontend-static-blog/package.json +6 -6
  140. package/templates/apps/frontend-status/package.json +8 -8
  141. package/templates/apps/frontend-status/src/locales/index.ts +1 -1
  142. package/templates/apps/mobile-app/README.md +58 -0
  143. package/templates/apps/mobile-app/app.config.ts +33 -0
  144. package/templates/apps/mobile-app/babel.config.js +5 -0
  145. package/templates/apps/mobile-app/metro.config.js +20 -0
  146. package/templates/apps/mobile-app/package.json +33 -0
  147. package/templates/apps/mobile-app/src/app/_layout.tsx +61 -0
  148. package/templates/apps/mobile-app/src/app/index.tsx +62 -0
  149. package/templates/apps/mobile-app/src/app/orders/[id].tsx +19 -0
  150. package/templates/apps/mobile-app/src/app/settings.tsx +51 -0
  151. package/templates/apps/mobile-app/src/client.ts +74 -0
  152. package/templates/apps/mobile-app/src/lib/deeplinks.ts +39 -0
  153. package/templates/apps/mobile-app/src/lib/notifications.ts +31 -0
  154. package/templates/apps/mobile-app/src/lib/sync.ts +29 -0
  155. package/templates/apps/mobile-app/src/persistence.ts +38 -0
  156. package/templates/apps/mobile-app/template.json +6 -0
  157. package/templates/apps/mobile-app/tests/deeplinks.test.ts +32 -0
  158. package/templates/apps/mobile-app/tests/notifications.test.ts +26 -0
  159. package/templates/apps/mobile-app/tests/sync.test.ts +34 -0
  160. package/templates/apps/mobile-app/tsconfig.json +10 -0
  161. package/templates/apps/mobile-app/voltro.mobile.ts +11 -0
  162. package/templates/patches/@effect__cluster@0.60.0.patch +6 -6
  163. package/dist/apiBuild-C8d74T6g.js +0 -2
  164. package/dist/checkCommand-DBvZvCh1.js +0 -2
  165. package/dist/dbCommand-CaIVTp-a.js +0 -2
  166. package/dist/frameworkTableAssembly-BTNirAAk.js +0 -2
  167. package/dist/inspect-DHeaMZak.js +0 -2
  168. package/dist/manifestBuild-Ck_90gZy.js +0 -2
  169. package/dist/serveCommand-BXl8mhZD.js +0 -1489
@@ -414,6 +414,29 @@ const choice = yield* step({
414
414
  })
415
415
  ```
416
416
 
417
+ ## Parallel steps
418
+
419
+ Steps with no data dependency run concurrently with plain `Effect.all` — no special API:
420
+
421
+ ```ts
422
+ const [jira, github] = yield* Effect.all(
423
+ [
424
+ step({ name: 'fetch-jira', success: JiraIssues, execute: fetchJira(projectKey) }),
425
+ step({ name: 'fetch-github', success: GithubPrs, execute: fetchGithub(repo) }),
426
+ ],
427
+ { concurrency: 'unbounded' },
428
+ )
429
+ ```
430
+
431
+ Both steps journal independently, and the durable guarantees hold across the join:
432
+
433
+ - The steps genuinely **overlap** — one is not secretly serialized behind the other.
434
+ - On a retry or an operator redrive, a parallel step that already **completed replays** from its journal; only the sibling that failed re-executes.
435
+
436
+ Both properties are pinned by a contract test against the real engine (`workflowParallelSteps.integration.test.ts`), so an engine upgrade that broke either would go red rather than quietly serializing your fan-out.
437
+
438
+ Name each parallel step distinctly — the name is the journal key, and two concurrent steps sharing one name would share one checkpoint. Deterministic-replay rules apply unchanged: the *set* of steps started must be stable for the same payload.
439
+
417
440
  ## Starting from the client
418
441
 
419
442
  Codegen synthesises an RPC for each workflow. To start it from React, use `useWorkflow(...)`; the call returns a run handle immediately and the durable work continues in the workflow engine:
@@ -1116,6 +1139,8 @@ Use flow control when the workflow is durable, but the resource it touches is li
1116
1139
 
1117
1140
  Voltro exposes the workflow engine's durable queue and rate limiter through `@voltro/workflow`:
1118
1141
 
1142
+ > **This page is about primitives you call INSIDE a running workflow.** For declaring `debounce` / `singleton` / `concurrency` / `throttle` / `rateLimit` / `batch` on the workflow itself — enforced before a run exists — see [Declarative flow control](/docs/workflows/declarative-flow-control).
1143
+
1119
1144
  ```ts
1120
1145
  import {
1121
1146
  durableQueue,
@@ -1203,6 +1228,345 @@ Queue workers and rate-limited steps still record normal workflow steps, timers,
1203
1228
 
1204
1229
 
1205
1230
 
1231
+ ---
1232
+
1233
+ <!-- source: en/workflows/declarative-flow-control.md -->
1234
+ ## Declarative flow control
1235
+
1236
+ _debounce, singleton, concurrency, throttle, rateLimit, batch, priority, timeouts and onFailure — declared on the workflow, enforced before the run exists._
1237
+
1238
+ Everything on this page is declared on `workflow({...})` and enforced at the **admission boundary** — the moment `ctx.workflows.start(...)` is called, before a durable run exists.
1239
+
1240
+ That timing is the whole point. Once a run is enqueued, the only tools left are cancel and sleep, and neither of them un-spends the durable entity. So "run this at most once per row per fifteen minutes" cannot be a primitive you call inside the body; it has to be a property of the declaration.
1241
+
1242
+ > **Not the same as [Flow control](/docs/workflows/flow-control).** That page covers `durableQueue` / `processQueue` / `rateLimit` — primitives you call **inside** a running workflow to bound the work it fans out. This page is about whether the run **starts at all**. They compose: a workflow can declare `concurrency` here and still use a durable queue in its body.
1243
+
1244
+ ## The shape
1245
+
1246
+ ```ts
1247
+ import { Schema } from 'effect'
1248
+ import { workflow } from '@voltro/workflow/define'
1249
+
1250
+ export const tourNarration = workflow({
1251
+ name: 'tourNarration',
1252
+ payload: Schema.Struct({
1253
+ rowId: Schema.String,
1254
+ tenantId: Schema.String,
1255
+ editedAt: Schema.Number,
1256
+ }),
1257
+ success: Schema.Void,
1258
+ idempotencyKey: ({ rowId, editedAt }) => `tour:${rowId}:${editedAt}`,
1259
+
1260
+ debounce: { key: (p) => `tour:${p.rowId}`, period: '15 minutes' },
1261
+ concurrency: { limit: 5, key: (p) => p.tenantId },
1262
+ timeouts: { start: '1 hour', finish: '10 minutes' },
1263
+ onFailure: 'narrationFailed',
1264
+ })
1265
+ ```
1266
+
1267
+ Every `key` callback receives the workflow's own **decoded payload type**. A misspelled field is a compile error, not a key that quietly becomes the string `"undefined"` and collapses every row in your deployment into one bucket.
1268
+
1269
+ ## `idempotencyKey` is the execution's identity — read this first
1270
+
1271
+ This is the single most expensive misunderstanding in the workflow API, and getting it wrong produces a design that looks right and silently stops working.
1272
+
1273
+ `idempotencyKey` is **not** a dedupe window. It is the execution's identity, permanently:
1274
+
1275
+ ```ts
1276
+ const a = yield* wf.execute({ id: 'same' })
1277
+ const b = yield* wf.execute({ id: 'same' }) // does NOT run — replays a's result
1278
+ ```
1279
+
1280
+ After the run completes the key is **spent**. A later, genuinely new invocation under that key is a silent no-op that returns the old output. Nothing errors and nothing logs, because from the engine's point of view you asked for a run it already has.
1281
+
1282
+ So a key must be unique **per unit of work you want to happen**:
1283
+
1284
+ | | |
1285
+ |---|---|
1286
+ | `` `tour:${rowId}` `` | wrong if the tour can ever be re-narrated |
1287
+ | `` `tour:${rowId}:${editedAt}` `` | right — every edit is a new unit of work |
1288
+
1289
+ ### And a flow-control key is a different thing
1290
+
1291
+ Conflating the two is what makes "I need to re-arm a key" feel like a missing feature. It is not missing; it is two fields:
1292
+
1293
+ - **`idempotencyKey`** — the execution's identity. **Varies** per unit of work.
1294
+ - **`debounce.key` / `singleton.key` / `concurrency.key`** — the **resource** runs compete for. **Stable**.
1295
+
1296
+ "One job, fifteen minutes after the last edit, latest state wins" is then the example at the top of this page: twenty edits mint twenty identities, and exactly one is ever admitted. There is no `restart: true` in this API because separating the two keys *is* the mechanism it would have been.
1297
+
1298
+ ## debounce — collapse a burst
1299
+
1300
+ ```ts
1301
+ debounce: { key: (p) => `tour:${p.rowId}`, period: '15 minutes', timeout: '1 hour' }
1302
+ ```
1303
+
1304
+ Starts sharing a key collapse into **one** pending row. The timer resets on every arrival, and the **latest payload wins** — which is what "narrate what settled" means.
1305
+
1306
+ `timeout` is a hard cap measured from the **first** start in the burst. It is optional and uncapped when unset, which is a real trade: an unbroken stream of starts arriving faster than `period` defers the run forever. We do not invent a default cap and we do not warn — instead the starvation is a number you can see:
1307
+
1308
+ ```
1309
+ voltro workflows flow
1310
+ ```
1311
+
1312
+ A `waiting=` climbing past a few multiples of your `period` is the signal. Set `timeout` when you see it, or from the start if the burst is user-driven.
1313
+
1314
+ ## singleton — one run per resource
1315
+
1316
+ ```ts
1317
+ singleton: { key: (p) => p.tenantId, mode: 'skip' } // newcomer stands down
1318
+ singleton: { key: (p) => p.tenantId, mode: 'cancel' } // newcomer evicts the incumbent
1319
+ ```
1320
+
1321
+ There is no default `mode`: `'skip'` discards the incoming request and `'cancel'` discards the running one, and choosing for you would silently throw away work either way.
1322
+
1323
+ Under `'skip'`, `start()` returns the **incumbent's** handle — a real, pollable run. Under `'cancel'`, the incumbent is cancelled **when the replacement actually starts**, not when it is queued. That matters when you also declare `debounce`: evicting at queue time would leave the whole quiet period with the old run dead and the new one not yet begun.
1324
+
1325
+ ## concurrency — bound what is in flight
1326
+
1327
+ ```ts
1328
+ concurrency: { limit: 5, key: (p) => p.tenantId }
1329
+ ```
1330
+
1331
+ At most `limit` runs in flight per key, **across every replica** — the count rides the shared admissions ledger, so three replicas with `limit: 5` are five runs, not fifteen. Excess starts queue and are admitted as slots free.
1332
+
1333
+ ### `pool` — share one budget across workflows
1334
+
1335
+ ```ts
1336
+ // embeddings.workflow.tsx
1337
+ concurrency: { limit: 10, pool: 'openai' }
1338
+ // summarize.workflow.tsx
1339
+ concurrency: { limit: 10, pool: 'openai' }
1340
+ ```
1341
+
1342
+ Without `pool`, the limit bounds *this workflow's* runs. With it, every workflow declaring the same pool name competes for **one** budget — the shape a rate-limited provider forces: five workflows that each call OpenAI must share ten slots, not hold ten each.
1343
+
1344
+ `key` still partitions *within* the pool: give each member `key: (p) => p.tenantId` and the shared budget applies per tenant.
1345
+
1346
+ Every member of a pool must declare the **same `limit`** — the boot fails otherwise. Two limits for one budget is a contradiction, and silently picking either would enforce a number somebody did not write.
1347
+
1348
+ ## throttle vs rateLimit — late, or gone
1349
+
1350
+ They are mutually exclusive, and declaring both is a boot error.
1351
+
1352
+ ```ts
1353
+ throttle: { limit: 100, period: '1 minute', key: (p) => p.tenantId } // QUEUES the excess
1354
+ rateLimit: { limit: 100, period: '1 minute', key: (p) => p.tenantId } // DROPS the excess
1355
+ ```
1356
+
1357
+ Reach for `throttle` when every start must eventually run. Reach for `rateLimit` when the excess is genuinely surplus and running it late is worse than not running it.
1358
+
1359
+ A dropped start is never silent: `start()` resolves to a handle with `status: 'dropped'` and a `retryAfterMs`, and the drop is a row in the admissions ledger with its key and reason.
1360
+
1361
+ `throttle` has no `burst` knob. The window is sliding, so its maximum instantaneous burst is already exactly `limit`; a separate knob could only duplicate it.
1362
+
1363
+ ## batch — many starts, one run
1364
+
1365
+ ```ts
1366
+ export const refreshIssues = workflow({
1367
+ name: 'refreshIssues',
1368
+ payload: Schema.Struct({ items: Schema.Array(IssueRef) }),
1369
+ success: Schema.Void,
1370
+ idempotencyKey: ({ items }) => `refresh:${items.length}:${items[0]?.issueKey ?? ''}`,
1371
+ batch: { item: IssueRef, key: (i) => i.tenantId, maxSize: 100, timeout: '30 seconds' },
1372
+ })
1373
+
1374
+ await ctx.workflows.start('refreshIssues', { tenantId, issueKey: 'ABC-1' })
1375
+ ```
1376
+
1377
+ Callers start it with a **single item**; the workflow's own `payload` is the **batch** shape. That mismatch is checked at declaration time — a decode failure on the batching replica is a failure nobody is watching.
1378
+
1379
+ `batch.item` is therefore also what an **arriving** start is validated against. A caller's payload is judged by the item schema; the run the drainer eventually starts is judged by the workflow's own `payload`. Two schemas, because there are genuinely two shapes — and a `WorkflowPayloadError` naming `items` on a `start()` call would be the framework asking the caller for the batch it is supposed to be building.
1380
+
1381
+ The timeout is a **deadline**, not a quiet period: it does not reset per item, or a steady trickle would never flush. `batch` and `debounce` cannot both be declared for exactly that reason.
1382
+
1383
+ ## priority
1384
+
1385
+ ```ts
1386
+ priority: (p) => (p.urgent ? 100 : 0)
1387
+ ```
1388
+
1389
+ Higher runs first out of the pending queue. Ties break by arrival, so an all-default deployment is FIFO rather than dialect-dependent.
1390
+
1391
+ ## timeouts
1392
+
1393
+ ```ts
1394
+ timeouts: { start: '1 hour', finish: '10 minutes' }
1395
+ ```
1396
+
1397
+ - **`start`** bounds how long a start may sit in the admission queue, measured from the **first** arrival in its group. A debounced run that never gets a quiet moment is a job that silently did not happen.
1398
+ - **`finish`** bounds the run itself once admitted, and also tightens the crash backstop on its concurrency slot.
1399
+
1400
+ Both expire into the same path as an exhausted retry: the run is recorded failed and `onFailure` fires. Declaring `timeouts.start` on a workflow that cannot defer is a boot error — it would never fire.
1401
+
1402
+ ## onFailure — the signal that replaces the sweep
1403
+
1404
+ ```ts
1405
+ onFailure: 'narrationFailed'
1406
+ ```
1407
+
1408
+ A **workflow name**, not a function. A closure cannot be journaled: the failure may be noticed by a different replica, minutes later, after the process that held it is gone.
1409
+
1410
+ It fires for every way a run fails to deliver — not only an exhausted retry:
1411
+
1412
+ - the body failed and retries are spent
1413
+ - `timeouts.finish` cancelled an overrunning run
1414
+ - `timeouts.start` expired a start that never got a slot
1415
+ - the workflow was renamed away while starts were queued
1416
+
1417
+ The last two produce **no run row at all**, which is exactly why polling `listRuns({ status: 'failed' })` could never see them.
1418
+
1419
+ The named workflow's payload is `WorkflowFailureReport`:
1420
+
1421
+ ```ts
1422
+ export const narrationFailed = workflow({
1423
+ name: 'narrationFailed',
1424
+ payload: Schema.Struct({
1425
+ workflow: Schema.String,
1426
+ payload: Schema.Unknown,
1427
+ errorTag: Schema.NullOr(Schema.String),
1428
+ errorMessage: Schema.NullOr(Schema.String),
1429
+ runId: Schema.NullOr(Schema.String),
1430
+ executionId: Schema.NullOr(Schema.String),
1431
+ reason: Schema.String,
1432
+ failedAt: Schema.Number,
1433
+ }),
1434
+ success: Schema.Void,
1435
+ idempotencyKey: ({ runId, failedAt }) => `failed:${runId ?? 'none'}:${failedAt}`,
1436
+ })
1437
+ ```
1438
+
1439
+ There is no `onFailure` for an `onFailure` — a handler that fails is logged and not re-notified, because the alternative is one run per failure per level with no floor.
1440
+
1441
+ ## encryptSteps
1442
+
1443
+ ```ts
1444
+ encryptSteps: true
1445
+ ```
1446
+
1447
+ `step({ input })` is journaled and shown in the dashboard, which is a feature and the reason people pass rich input. For a step carrying personal data it is also a **second copy** outside the `.encrypted()` boundary the governance plugin establishes for tables.
1448
+
1449
+ `encryptSteps` closes it, reusing the **same** cipher — one key, one rotation story:
1450
+
1451
+ ```ts
1452
+ governancePlugin({ fieldEncryption: { secret: 'VOLTRO_FIELD_ENCRYPTION_KEY' } })
1453
+ ```
1454
+
1455
+ Declaring it without that plugin configured is a **boot refusal**, not a warning. A plaintext fallback would leave the declaration reading as protection while every step input sat readable.
1456
+
1457
+ ## cancelOn — stop live work when a correlated event arrives
1458
+
1459
+ ```ts
1460
+ workflow({
1461
+ name: 'tourNarration',
1462
+ payload: { rowId: Schema.String, issueKey: Schema.String },
1463
+ idempotencyKey: (p) => `tour:${p.rowId}`,
1464
+ cancelOn: [{
1465
+ event: 'jira.issue.deleted',
1466
+ schema: JiraIssueDeleted,
1467
+ match: (event, payload) => event.issueKey === payload.issueKey,
1468
+ }],
1469
+ })
1470
+ ```
1471
+
1472
+ Both sides are typed: `event` from the entry's own `schema`, `payload` from the workflow's.
1473
+
1474
+ **Why it is a declaration and not a race inside the body.** You can express "stop when the issue is deleted" with `awaitEvent` and an interrupt. That works while the body is *running*. It does not work while the run is sleeping for six hours, suspended on a signal, or still sitting in the admission queue — which is the case you wanted cancellation for. The event has to reach a run whose fiber is not executing anything, and only something outside the body can do that.
1475
+
1476
+ So it is swept: a coordinated tick reads events published since a durable watermark, resolves each declaring workflow's live runs, and cancels the ones that correlate.
1477
+
1478
+ **It also discards queued starts.** Cancelling only the running one leaves a debounced or concurrency-queued duplicate to start seconds later, against the row that was just deleted — the exact outcome the declaration was meant to prevent, arriving late enough that nobody connects the two.
1479
+
1480
+ | Field | Meaning |
1481
+ |---|---|
1482
+ | `event` | The name, exactly as `ctx.events.publish` writes it |
1483
+ | `schema` | Decoded before `match` runs. An event whose shape does not decode is **reported and never matched** — cancelling on an event you could not read is cancelling blind |
1484
+ | `match` | Required. Write `match: () => true` if you really mean "every live run" |
1485
+ | `within` | Only cancel runs started within this window before the event |
1486
+ | `reason` | Recorded on the run's `run-cancelled` event; defaults to `cancelOn:<event>` |
1487
+
1488
+ `match` has **no default** for the same reason `singleton.mode` has none: the omitted case is "cancel every live run of this workflow", which is a legitimate thing to want and a catastrophic thing to acquire by forgetting a line.
1489
+
1490
+ **A run that started *after* the event is never cancelled.** Without that rule, a sweep catching up after a deployment gap reads an hour of history and kills runs that started in the meantime — and the symptom (fresh work cancelled for no visible reason) looks nothing like its cause (a restart).
1491
+
1492
+ ## Bulk cancel and bulk replay
1493
+
1494
+ A bad deploy leaves four thousand runs that must all stop, or four thousand that must all be re-driven once the downstream is fixed.
1495
+
1496
+ ```
1497
+ voltro workflows cancel-many --workflow tourNarration --reason "bad deploy"
1498
+ voltro workflows cancel-many --workflow tourNarration --reason "bad deploy" --commit
1499
+ voltro workflows replay-many --status failed --mode redrive --limit 200 --commit
1500
+ ```
1501
+
1502
+ Three things are deliberately stricter than the obvious design:
1503
+
1504
+ - **`--limit` is required and there is no "all".** The cap *is* the blast radius. `truncated` in the result says whether more matched, so "did I get all of them" stays answerable without an unbounded verb ever existing.
1505
+ - **It is a dry run unless you pass `--commit`.** The default for a verb that can stop a thousand runs is the one that stops none. The dashboard panel enforces the same order: the apply button does not exist until a preview has returned a number.
1506
+ - **`--reason` is required for a cancel.** It lands on every affected run's `run-cancelled` event, so "why did four thousand runs stop on the 8th" has an answer in the table an operator is already reading.
1507
+
1508
+ The result is per-run, not a count: `succeeded`, `failed` (with the reason for each) and `skipped` (with what made each ineligible) are three different outcomes. A bulk op that reports "4000 cancelled" while forty failed is how people learn not to trust bulk ops.
1509
+
1510
+ Eligibility is fixed by the verb: a cancel acts on `running` and `suspended` runs; `replay --mode redrive` on `failed` only (redrive resumes from the step that died, which only exists for a failure); `replay --mode retry` on `failed` and `cancelled`. `--mode` has no default because the two cost very different amounts.
1511
+
1512
+ ## What a deferred start returns
1513
+
1514
+ `start()` no longer always returns a running handle:
1515
+
1516
+ ```ts
1517
+ const handle = await ctx.workflows.start('tourNarration', payload)
1518
+
1519
+ if (handle.status === 'queued') { /* handle.deferral.dueAt tells you when */ }
1520
+ if (handle.status === 'dropped') { /* over a rateLimit cap; it will NOT run */ }
1521
+ if (handle.status === 'skipped') { /* handle.executionId is the incumbent */ }
1522
+ ```
1523
+
1524
+ `executionId` is `null` for `queued` and `dropped`, because there is no execution and there may never be one. Inventing an id there would produce a handle that polls `status: 'unknown'` forever.
1525
+
1526
+ ### Blocking callers
1527
+
1528
+ `ctx.workflows.run(...)` and `start({ wait: true })` block for the run's **result**, and a start that was collapsed into a future run has none. A workflow declaring `debounce` / `batch` / `throttle` / `concurrency` therefore **refuses** those callers with an error naming both halves. Controls with a synchronous answer — `singleton`, `rateLimit` — keep working on every path (`rateLimit` throws `WorkflowRateLimitedError`, `singleton: 'skip'` throws `WorkflowSingletonHeldError` carrying the incumbent's id).
1529
+
1530
+ ## Pausing a workflow
1531
+
1532
+ ```
1533
+ voltro workflows pause tourNarration --reason "deploying a fix"
1534
+ voltro workflows unpause tourNarration
1535
+ ```
1536
+
1537
+ A pause makes starts **collect**, never discard — so you come back to a backlog rather than a hole in the data. `unpause` drains it.
1538
+
1539
+ The pause is a row, so it applies fleet-wide; each replica picks it up on its next drain tick (~1 s).
1540
+
1541
+ ## Seeing what happened
1542
+
1543
+ ```
1544
+ voltro workflows flow
1545
+ voltro workflows flow --workflow tourNarration --format json
1546
+ ```
1547
+
1548
+ or `GET /_voltro/inspect/workflows/flow-control`.
1549
+
1550
+ This is not optional colour. A debounce that collapses nineteen starts into one is indisputably correct behaviour **and** indistinguishable from nineteen starts vanishing — unless something writes down that it happened. So every decision is a row in `_voltro_workflow_admissions`, with its key, its reason, how many starts folded into it, and how long it waited.
1551
+
1552
+ Three questions it answers:
1553
+
1554
+ | Question | Where |
1555
+ |---|---|
1556
+ | "Twenty edits, one run — did that work, or did I lose nineteen?" | `collapsed` |
1557
+ | "Nothing has run for an hour. Stuck, or quiet?" | `waiting=` on the queued row |
1558
+ | "Why did *my* run not start?" | the ledger's `outcome` + `reason` |
1559
+
1560
+ ## Cost
1561
+
1562
+ A workflow that declares **no** control takes exactly the code path it took before this feature existed — no query, no branch beyond one map lookup.
1563
+
1564
+ A workflow that declares one pays only for that one: an undeclared control costs zero round trips. The rate/throttle window reads at most `limit` rows, which is why `limit` is a throughput knob and not somewhere to put 10⁶.
1565
+
1566
+ The drainer runs on **one replica per tick** through the same claim arbiter the cron scheduler uses. N replicas draining at once would each see a free slot and each take it.
1567
+
1568
+
1569
+
1206
1570
  ---
1207
1571
 
1208
1572
  <!-- source: en/workflows/versioning.md -->
@@ -1314,6 +1678,9 @@ queued/running/waiting work by workflow lane and start source.
1314
1678
  voltro workflows list --status running --tail 50
1315
1679
  voltro workflows list --tag notes.summarise --format json
1316
1680
  voltro workflows list --dead-letter # failed runs not yet discarded
1681
+ voltro workflows list --statuses failed,cancelled --q orders --since 2026-08-01T00:00:00Z
1682
+ voltro workflows list --id-prefix wfrun_01K # matches the run id OR the execution id
1683
+ voltro workflows stats --hours 24 # bucketed activity sparkline + per-workflow totals
1317
1684
  voltro workflows start notes.summarise --payload '{"noteId":"note_123"}'
1318
1685
  voltro workflows show wfrun_01H...
1319
1686
  voltro workflows retry wfrun_01H...
@@ -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.29.0",
16
- "@voltro/cli": "0.29.0",
17
- "@voltro/database": "0.29.0",
18
- "@voltro/env": "0.29.0",
19
- "@voltro/protocol": "0.29.0",
20
- "@voltro/runtime": "0.29.0",
15
+ "@voltro/ai": "0.30.1",
16
+ "@voltro/cli": "0.30.1",
17
+ "@voltro/database": "0.30.1",
18
+ "@voltro/env": "0.30.1",
19
+ "@voltro/protocol": "0.30.1",
20
+ "@voltro/runtime": "0.30.1",
21
21
  "effect": "^3.22.0"
22
22
  },
23
23
  "devDependencies": {
24
- "@voltro/testing": "0.29.0",
24
+ "@voltro/testing": "0.30.1",
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.29.0",
17
- "@voltro/database": "0.29.0",
18
- "@voltro/env": "0.29.0",
19
- "@voltro/plugin-auth": "0.29.0",
20
- "@voltro/protocol": "0.29.0",
21
- "@voltro/runtime": "0.29.0",
22
- "@voltro/sql-postgres": "0.29.0",
16
+ "@voltro/cli": "0.30.1",
17
+ "@voltro/database": "0.30.1",
18
+ "@voltro/env": "0.30.1",
19
+ "@voltro/plugin-auth": "0.30.1",
20
+ "@voltro/protocol": "0.30.1",
21
+ "@voltro/runtime": "0.30.1",
22
+ "@voltro/sql-postgres": "0.30.1",
23
23
  "effect": "^3.22.0"
24
24
  },
25
25
  "devDependencies": {
26
- "@voltro/testing": "0.29.0",
26
+ "@voltro/testing": "0.30.1",
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.29.0",
17
- "@voltro/database": "0.29.0",
18
- "@voltro/env": "0.29.0",
19
- "@voltro/plugin-multitenancy": "0.29.0",
20
- "@voltro/protocol": "0.29.0",
21
- "@voltro/runtime": "0.29.0",
16
+ "@voltro/cli": "0.30.1",
17
+ "@voltro/database": "0.30.1",
18
+ "@voltro/env": "0.30.1",
19
+ "@voltro/plugin-multitenancy": "0.30.1",
20
+ "@voltro/protocol": "0.30.1",
21
+ "@voltro/runtime": "0.30.1",
22
22
  "effect": "^3.22.0"
23
23
  },
24
24
  "devDependencies": {
25
- "@voltro/testing": "0.29.0",
25
+ "@voltro/testing": "0.30.1",
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.29.0",
17
- "@voltro/database": "0.29.0",
18
- "@voltro/env": "0.29.0",
19
- "@voltro/plugin-deactivation": "0.29.0",
20
- "@voltro/protocol": "0.29.0",
21
- "@voltro/runtime": "0.29.0",
16
+ "@voltro/cli": "0.30.1",
17
+ "@voltro/database": "0.30.1",
18
+ "@voltro/env": "0.30.1",
19
+ "@voltro/plugin-deactivation": "0.30.1",
20
+ "@voltro/protocol": "0.30.1",
21
+ "@voltro/runtime": "0.30.1",
22
22
  "effect": "^3.22.0"
23
23
  },
24
24
  "devDependencies": {
25
- "@voltro/testing": "0.29.0",
25
+ "@voltro/testing": "0.30.1",
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.29.0",
17
- "@voltro/database": "0.29.0",
18
- "@voltro/env": "0.29.0",
19
- "@voltro/plugin-mail": "0.29.0",
20
- "@voltro/plugin-multitenancy": "0.29.0",
21
- "@voltro/protocol": "0.29.0",
22
- "@voltro/runtime": "0.29.0",
16
+ "@voltro/cli": "0.30.1",
17
+ "@voltro/database": "0.30.1",
18
+ "@voltro/env": "0.30.1",
19
+ "@voltro/plugin-mail": "0.30.1",
20
+ "@voltro/plugin-multitenancy": "0.30.1",
21
+ "@voltro/protocol": "0.30.1",
22
+ "@voltro/runtime": "0.30.1",
23
23
  "effect": "^3.22.0",
24
24
  "react": "^19.0.0"
25
25
  },
26
26
  "devDependencies": {
27
- "@voltro/testing": "0.29.0",
27
+ "@voltro/testing": "0.30.1",
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.29.0",
17
- "@voltro/database": "0.29.0",
18
- "@voltro/env": "0.29.0",
19
- "@voltro/plugin-multitenancy": "0.29.0",
20
- "@voltro/plugin-storage": "0.29.0",
21
- "@voltro/protocol": "0.29.0",
22
- "@voltro/runtime": "0.29.0",
23
- "@voltro/sql-mysql": "0.29.0",
16
+ "@voltro/cli": "0.30.1",
17
+ "@voltro/database": "0.30.1",
18
+ "@voltro/env": "0.30.1",
19
+ "@voltro/plugin-multitenancy": "0.30.1",
20
+ "@voltro/plugin-storage": "0.30.1",
21
+ "@voltro/protocol": "0.30.1",
22
+ "@voltro/runtime": "0.30.1",
23
+ "@voltro/sql-mysql": "0.30.1",
24
24
  "effect": "^3.22.0"
25
25
  },
26
26
  "devDependencies": {
27
- "@voltro/testing": "0.29.0",
27
+ "@voltro/testing": "0.30.1",
28
28
  "typescript": "^6.0.3",
29
29
  "vitest": "^4.1.10"
30
30
  }
@@ -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.29.0",
17
- "@voltro/database": "0.29.0",
18
- "@voltro/env": "0.29.0",
19
- "@voltro/plugin-multitenancy": "0.29.0",
20
- "@voltro/protocol": "0.29.0",
21
- "@voltro/runtime": "0.29.0",
22
- "@voltro/sql-sqlite": "0.29.0",
16
+ "@voltro/cli": "0.30.1",
17
+ "@voltro/database": "0.30.1",
18
+ "@voltro/env": "0.30.1",
19
+ "@voltro/plugin-multitenancy": "0.30.1",
20
+ "@voltro/protocol": "0.30.1",
21
+ "@voltro/runtime": "0.30.1",
22
+ "@voltro/sql-sqlite": "0.30.1",
23
23
  "effect": "^3.22.0"
24
24
  },
25
25
  "devDependencies": {
26
- "@voltro/testing": "0.29.0",
26
+ "@voltro/testing": "0.30.1",
27
27
  "typescript": "^6.0.3",
28
28
  "vitest": "^4.1.10"
29
29
  }
@@ -11,17 +11,17 @@
11
11
  "test": "voltro test"
12
12
  },
13
13
  "dependencies": {
14
- "@voltro/cli": "0.29.0",
15
- "@voltro/database": "0.29.0",
16
- "@voltro/env": "0.29.0",
17
- "@voltro/plugin-multitenancy": "0.29.0",
18
- "@voltro/plugin-storage": "0.29.0",
19
- "@voltro/protocol": "0.29.0",
20
- "@voltro/runtime": "0.29.0",
14
+ "@voltro/cli": "0.30.1",
15
+ "@voltro/database": "0.30.1",
16
+ "@voltro/env": "0.30.1",
17
+ "@voltro/plugin-multitenancy": "0.30.1",
18
+ "@voltro/plugin-storage": "0.30.1",
19
+ "@voltro/protocol": "0.30.1",
20
+ "@voltro/runtime": "0.30.1",
21
21
  "effect": "^3.22.0"
22
22
  },
23
23
  "devDependencies": {
24
- "@voltro/testing": "0.29.0",
24
+ "@voltro/testing": "0.30.1",
25
25
  "typescript": "^6.0.3",
26
26
  "vitest": "^4.1.10"
27
27
  }
@@ -13,19 +13,19 @@
13
13
  "dependencies": {
14
14
  "@effect/platform": "^0.97.0",
15
15
  "@effect/rpc": "^0.76.0",
16
- "@voltro/cli": "0.29.0",
17
- "@voltro/cms": "0.29.0",
18
- "@voltro/database": "0.29.0",
19
- "@voltro/env": "0.29.0",
20
- "@voltro/plugin-auth": "0.29.0",
21
- "@voltro/plugin-multitenancy": "0.29.0",
22
- "@voltro/protocol": "0.29.0",
23
- "@voltro/runtime": "0.29.0",
24
- "@voltro/sql-postgres": "0.29.0",
16
+ "@voltro/cli": "0.30.1",
17
+ "@voltro/cms": "0.30.1",
18
+ "@voltro/database": "0.30.1",
19
+ "@voltro/env": "0.30.1",
20
+ "@voltro/plugin-auth": "0.30.1",
21
+ "@voltro/plugin-multitenancy": "0.30.1",
22
+ "@voltro/protocol": "0.30.1",
23
+ "@voltro/runtime": "0.30.1",
24
+ "@voltro/sql-postgres": "0.30.1",
25
25
  "effect": "^3.22.0"
26
26
  },
27
27
  "devDependencies": {
28
- "@voltro/testing": "0.29.0",
28
+ "@voltro/testing": "0.30.1",
29
29
  "typescript": "^6.0.3",
30
30
  "vitest": "^4.1.10"
31
31
  }