okengine 0.12.0 → 0.14.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 (127) hide show
  1. package/manifest.v1.schema.json +7 -1
  2. package/package.json +2 -2
  3. package/site/content/docs/ai/mcp.mdx +27 -2
  4. package/site/content/docs/elements/ai.mdx +58 -11
  5. package/site/content/docs/elements/clock.mdx +27 -12
  6. package/site/content/docs/elements/flow.mdx +6 -3
  7. package/site/content/docs/elements/signal.mdx +71 -25
  8. package/site/content/docs/elements/store.mdx +21 -1
  9. package/site/content/docs/get-started/installation.mdx +2 -2
  10. package/site/content/docs/providers/index.mdx +1 -0
  11. package/site/content/docs/recipes/dragonfly.mdx +4 -3
  12. package/site/content/docs/recipes/redis.mdx +4 -5
  13. package/site/content/docs/recipes/valkey.mdx +5 -3
  14. package/site/content/docs/reference/configuration.mdx +3 -1
  15. package/site/content/docs/reference/environment-variables.mdx +5 -5
  16. package/site/content/docs/reference/fx.mdx +27 -22
  17. package/src/cli/ai-setup/recommend.test.ts +25 -0
  18. package/src/cli/ai-setup/recommend.ts +8 -3
  19. package/src/cli/load-config.images.test.ts +1 -0
  20. package/src/compiler/effects-infer.ts +58 -3
  21. package/src/compiler/extract.test.ts +68 -1
  22. package/src/compiler/extract.ts +70 -2
  23. package/src/compiler/response.ts +45 -1
  24. package/src/console/server/ai.ts +5 -2
  25. package/src/console/server/flows.ts +1 -0
  26. package/src/console/server/serve.ts +17 -2
  27. package/src/console/ui-next/dist/assets/cache-glyph-BanhLsEY.js +1 -0
  28. package/src/console/ui-next/dist/assets/flows-page-DxDsOd4f.js +1 -0
  29. package/src/console/ui-next/dist/assets/http-method-CJCBYL2j.js +1 -0
  30. package/src/console/ui-next/dist/assets/{index-DX248G39.js → index-Ce6WKWKM.js} +3 -3
  31. package/src/console/ui-next/dist/assets/observability-page-BEZDzyYh.js +4 -0
  32. package/src/console/ui-next/dist/assets/trace-detail-sheet-DFLFfUUX.js +2 -0
  33. package/src/console/ui-next/dist/assets/units-page-C0gW6Kdo.js +1 -0
  34. package/src/console/ui-next/dist/assets/{vault-page-CSOYMHhO.js → vault-page-DuKzqwzW.js} +1 -1
  35. package/src/console/ui-next/dist/index.html +1 -1
  36. package/src/console/ui-next/seed-invoke-host.ts +2 -0
  37. package/src/console/ui-next/src/features/flows/graph/build-flow-graph.test.ts +18 -0
  38. package/src/console/ui-next/src/features/flows/graph/build-flow-graph.ts +14 -1
  39. package/src/console/ui-next/src/features/flows/graph/neighborhood.test.ts +17 -0
  40. package/src/console/ui-next/src/features/flows/graph/neighborhood.ts +16 -3
  41. package/src/console/ui-next/src/features/flows/traces/effect-kind.ts +3 -1
  42. package/src/console/ui-next/src/features/flows/traces/effect-summary.ts +24 -0
  43. package/src/console/ui-next/src/features/flows/traces/trace-detail-sheet.tsx +9 -3
  44. package/src/console/ui-next/src/features/flows/traces/trace-detail.test.ts +10 -1
  45. package/src/console/ui-next/src/features/observability/lib/ask-count.test.ts +25 -0
  46. package/src/console/ui-next/src/features/observability/lib/ask-count.ts +4 -1
  47. package/src/console/ui-next/src/features/units/detail/effects-summary.tsx +12 -4
  48. package/src/docker/docker.test.ts +1 -1
  49. package/src/docker/dockerfile.ts +1 -1
  50. package/src/docker/helpers.ts +28 -0
  51. package/src/docker/recipes/dragonfly.ts +3 -6
  52. package/src/docker/recipes/redis.ts +2 -6
  53. package/src/docker/recipes/valkey.ts +2 -6
  54. package/src/drivers/ai-anthropic.ts +5 -0
  55. package/src/drivers/ai-ollama.ts +49 -30
  56. package/src/drivers/ai-openai-compatible.ts +57 -46
  57. package/src/drivers/ai-providers.test.ts +3 -0
  58. package/src/drivers/bun-native-completeness.test.ts +7 -9
  59. package/src/drivers/redis.ts +11 -4
  60. package/src/drivers/signal-redis.ts +24 -14
  61. package/src/drivers/signal-types.ts +2 -1
  62. package/src/drivers/types.ts +1 -1
  63. package/src/elements/ai/declare.ts +109 -0
  64. package/src/elements/ai/errors.test.ts +5 -1
  65. package/src/elements/ai/errors.ts +30 -2
  66. package/src/elements/ai/eval.ts +4 -6
  67. package/src/elements/ai/mcp-client.test.ts +206 -0
  68. package/src/elements/ai/mcp-client.ts +362 -0
  69. package/src/elements/ai/mcp-http.ts +159 -0
  70. package/src/elements/ai/mcp-mock.ts +134 -0
  71. package/src/elements/ai/mcp-protocol.ts +234 -0
  72. package/src/elements/ai/mcp-stdio.test.ts +50 -0
  73. package/src/elements/ai/mcp-stdio.ts +212 -0
  74. package/src/elements/ai/mcp-transport.ts +70 -0
  75. package/src/elements/ai/runtime.ts +159 -29
  76. package/src/elements/ai.test.ts +139 -0
  77. package/src/elements/ai.ts +16 -0
  78. package/src/elements/clock/health.test.ts +43 -0
  79. package/src/elements/clock/runtime.ts +55 -2
  80. package/src/elements/clock/schedule.ts +71 -142
  81. package/src/elements/clock.test.ts +9 -40
  82. package/src/elements/clock.ts +6 -1
  83. package/src/elements/gate/runtime.ts +3 -3
  84. package/src/elements/index.ts +2 -0
  85. package/src/elements/signal/declare.ts +2 -1
  86. package/src/elements/signal/runtime.ts +16 -1
  87. package/src/elements/signal.ts +1 -0
  88. package/src/elements/store/cache.test.ts +2 -0
  89. package/src/elements/store/cache.ts +3 -3
  90. package/src/elements/store/declare.ts +7 -0
  91. package/src/elements/store/kv-sql.test.ts +86 -0
  92. package/src/elements/store/kv-sql.ts +178 -0
  93. package/src/elements/store/runtime.ts +35 -9
  94. package/src/elements/store.test.ts +2 -0
  95. package/src/elements/vault/builtin-adapter.ts +17 -0
  96. package/src/full.ts +2 -0
  97. package/src/index.ts +4 -0
  98. package/src/kernel/app.ts +97 -64
  99. package/src/kernel/auto-registry.test.ts +5 -0
  100. package/src/kernel/boot-bind/ai.ts +24 -0
  101. package/src/kernel/boot-bind/clock.ts +2 -0
  102. package/src/kernel/boot-bind/store.test.ts +150 -1
  103. package/src/kernel/boot-bind/store.ts +66 -1
  104. package/src/kernel/boot.ts +3 -1
  105. package/src/kernel/element-registries.ts +4 -1
  106. package/src/kernel/fx-dead-letters.test.ts +77 -0
  107. package/src/kernel/fx.test.ts +22 -0
  108. package/src/kernel/fx.ts +112 -6
  109. package/src/kernel/http-stream.test.ts +174 -0
  110. package/src/kernel/index.ts +2 -0
  111. package/src/manifest/diff.test.ts +13 -0
  112. package/src/manifest/diff.ts +15 -0
  113. package/src/manifest/mcp-ref.ts +88 -0
  114. package/src/manifest/types.ts +33 -2
  115. package/src/manifest/validate.test.ts +20 -0
  116. package/src/mcp/docs-server.ts +1 -1
  117. package/src/mcp/server.ts +1 -1
  118. package/src/plugins/compression.test.ts +21 -0
  119. package/src/plugins/compression.ts +1 -0
  120. package/src/runtime/bun.ts +41 -4
  121. package/src/test/reset-element-registries.ts +2 -0
  122. package/src/console/ui-next/dist/assets/cache-glyph-CLPBqZeb.js +0 -1
  123. package/src/console/ui-next/dist/assets/flows-page-C_Tas1E1.js +0 -1
  124. package/src/console/ui-next/dist/assets/http-method-BJ92Z_ke.js +0 -1
  125. package/src/console/ui-next/dist/assets/observability-page-BwVS5Jvm.js +0 -4
  126. package/src/console/ui-next/dist/assets/trace-detail-sheet-D16lWQMt.js +0 -2
  127. package/src/console/ui-next/dist/assets/units-page-C5KOI7qG.js +0 -1
@@ -111,6 +111,11 @@
111
111
  "type": "string",
112
112
  "pattern": "^(sql|kv|files|index):.+$"
113
113
  },
114
+ "SignalResourceRef": {
115
+ "description": "Dead-letter read via fx.deadLetters — signal:<name>, not a store facet.",
116
+ "type": "string",
117
+ "pattern": "^signal:.+$"
118
+ },
114
119
  "SignalRef": {
115
120
  "type": "string",
116
121
  "minLength": 1
@@ -145,7 +150,8 @@
145
150
  {
146
151
  "const": "runs",
147
152
  "description": "Runs wide-event store via fx.runs (observability read)."
148
- }
153
+ },
154
+ { "$ref": "#/$defs/SignalResourceRef" }
149
155
  ]
150
156
  },
151
157
  "uniqueItems": true
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okengine",
3
- "version": "0.12.0",
3
+ "version": "0.14.1",
4
4
  "description": "One law. Eight elements. Ten exports. One package. One manifest. Every backend need is derived, never added.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -271,6 +271,6 @@
271
271
  "react": "^19.2.8"
272
272
  },
273
273
  "engines": {
274
- "bun": ">=1.3.14"
274
+ "bun": ">=1.4.0"
275
275
  }
276
276
  }
@@ -1,11 +1,13 @@
1
1
  ---
2
2
  title: "MCP"
3
- description: "The runtime MCP server on :6535 read tools for Manifest, schemas, effects, and traces, write actions gated by per-call human confirmation plus the read-only docs MCP on :6536."
3
+ description: "OKE serves MCP on :6535 so agents can operate a running app, and apps consume external MCP servers as allowlisted tools on the same fx.call path."
4
4
  icon: "Plug"
5
5
  source: "docs/spec/unified-theory.md"
6
6
  ---
7
7
 
8
- OKE serves an [MCP](https://modelcontextprotocol.io) endpoint on port **6535** so an AI agent can operate your running app: read its Manifest and traces, and take a small set of safe actions. It speaks JSON-RPC over HTTP (MCP protocol `2024-11-05`), requires a Bearer token **even on localhost**, and never forwards that token upstream — adapters receive structured operator ids instead.
8
+ Two directions, same protocol. **Serve:** OKE exposes your running app on port **6535** so an agent can read the Manifest and take confirmed actions. **Consume:** your Flows call _external_ MCP servers as tools declared on `ai.mcpServer`, dispatched through `fx.call`.
9
+
10
+ The server on **6535** speaks JSON-RPC over HTTP (MCP protocol `2024-11-05`), requires a Bearer token **even on localhost**, and never forwards that token upstream — adapters receive structured operator ids instead.
9
11
 
10
12
  <Callout title="The one rule">
11
13
  MCP inherits the operator's capability and can never exceed it. Server-level controls alone are
@@ -96,6 +98,29 @@ Consumed tokens cannot be replayed; the next write needs a new confirmation. A m
96
98
 
97
99
  The docs content ships inside the `okengine` package, so the index your agent searches is exactly the version you have installed. If the surface cannot boot (missing content, busy port), `oke dev` prints `Docs MCP skipped — …` and continues — docs search never takes your dev session down.
98
100
 
101
+ ## Consume — `ai.mcpServer`
102
+
103
+ Your app can _call_ other MCP servers. Those tools are not a second loop — they join `fx.ask` / `ai.agent` the same way a Flow tool does.
104
+
105
+ ```typescript
106
+ export const github = ai.mcpServer("github", {
107
+ url: "https://mcp.example/github",
108
+ auth: { bearer: githubToken },
109
+ tools: ["create_issue"], // required allowlist
110
+ });
111
+
112
+ await fx.ask(triage, input, { tools: [github.tool("create_issue")] });
113
+ ```
114
+
115
+ | Rule | Meaning |
116
+ | ---------- | ------------------------------------------------------------------------------------------------- |
117
+ | Allowlist | `tools` is required. Extra names from `tools/list` are dropped. |
118
+ | Capability | `mcp:<server>/<tool>` on `effects.calls` — undeclared throws **OKE1007**. |
119
+ | Transport | `url` (Streamable HTTP) **or** `command` + `args` (stdio). Not both. |
120
+ | Cancel | HTTP aborts the fetch / SSE stream. stdio sends `notifications/cancelled` then kills the process. |
121
+
122
+ Console draws each declared server as one **AI** node on the flow graph; Units chips read `Call github → create_issue`; traces label the effect **MCP call**. There is no separate MCP page or connect UI.
123
+
99
124
  ## Learn more
100
125
 
101
126
  - [Agent contracts](/docs/ai/skills) — what agents are taught about the system they operate
@@ -68,14 +68,15 @@ export const triage = smart.prompt("ticket-triage", {
68
68
 
69
69
  </Steps>
70
70
 
71
- ## The four building blocks
71
+ ## The building blocks
72
72
 
73
- | Declaration | Produces |
74
- | -------------------------- | ------------------------------------------------------------------ |
75
- | `ai.model(name, opts)` | Logical model binding — provider / tier / concrete model id |
76
- | `model.prompt(name, opts)` | Versioned prompt with typed in/out, evals, budget |
77
- | `ai.embed(name, opts)` | Embedding pipeline into a `store.index` (searched via `fx.search`) |
78
- | `ai.agent(name, opts)` | Bounded agent whose tools are **your own flows** |
73
+ | Declaration | Produces |
74
+ | -------------------------- | ------------------------------------------------------------------------------- |
75
+ | `ai.model(name, opts)` | Logical model binding — provider / tier / model id / optional `driverId` |
76
+ | `model.prompt(name, opts)` | Versioned prompt with typed in/out, evals, budget |
77
+ | `ai.embed(name, opts)` | Embedding pipeline into a `store.index` (searched via `fx.search`) |
78
+ | `ai.agent(name, opts)` | Bounded agent whose tools are **your own flows** |
79
+ | `ai.mcpServer(name, opts)` | External MCP server — allowlisted tools join `fx.call` as `mcp:<server>/<tool>` |
79
80
 
80
81
  <AiBlocks />
81
82
 
@@ -105,9 +106,11 @@ const out = await fx.ask(summarizeNote, { title, body });
105
106
  // out.via is the logical model that answered
106
107
  ```
107
108
 
108
- Resolution: `fx.ask(…, { via })` overrides `prompt.via`, else the prompt’s bound model. On each model the runtime retries **once** for retryable failures (timeout / 429 / 5xx / network), then advances. Permanent failures (401 / other 4xx / schema invalid) stop the chain. When every eligible attempt fails, `fx.ask` throws — map that to a typed error in the flow (no silent text excerpt).
109
+ Resolution: `fx.ask(…, { via })` overrides `prompt.via`, else the prompt’s bound model. On each model the runtime retries **once** for retryable failures (timeout / 429 / 5xx / network), then advances.
109
110
 
110
- `timeout` uses the same duration vocabulary as Clock (`"30s"`, `"2m"`, or a millisecond number). Ask-time `fx.ask(…, { timeout: "10s" })` overrides the prompt. Omit = no artificial cap. Cost caps stay on `budget`.
111
+ Permanent failures (401 / other 4xx / schema invalid / client disconnect / `maxCostPerCall` exceeded) stop the chain. When every eligible attempt fails, `fx.ask` throws map that to a typed error in the flow (no silent text excerpt).
112
+
113
+ `timeout` uses the same duration vocabulary as Clock (`"30s"`, `"2m"`, or a millisecond number). Ask-time `fx.ask(…, { timeout: "10s" })` overrides the prompt. Omit = no artificial cap. `budget.maxCostPerCall` is enforced at runtime (not just observed). Set `driverId` on a model to open a different protocol than the app default.
111
114
 
112
115
  ## Declared guardrails
113
116
 
@@ -126,6 +129,22 @@ const result = await fx.ask(triage, input, {
126
129
  });
127
130
  ```
128
131
 
132
+ External MCP tools take the same path. Declare a server with a **required** allowlist, then pass `server.tool("…")` — never whatever `tools/list` happens to expose:
133
+
134
+ ```typescript
135
+ export const github = ai.mcpServer("github", {
136
+ url: "https://mcp.example/github",
137
+ auth: { bearer: githubToken },
138
+ tools: ["create_issue"],
139
+ });
140
+
141
+ await fx.ask(triage, input, {
142
+ tools: [github.tool("create_issue")],
143
+ });
144
+ ```
145
+
146
+ Capability refs are `mcp:<server>/<tool>`. An undeclared call throws **OKE1007**. See [MCP](/docs/ai/mcp).
147
+
129
148
  ## Agents with real guardrails
130
149
 
131
150
  An agent's tools are your flows — each carrying its own gates, effects, and typed errors, so the agent can never do anything a flow couldn't:
@@ -139,7 +158,7 @@ export const support = ai.agent("support", {
139
158
  });
140
159
  ```
141
160
 
142
- `maxSteps` bounds the loop; `budget.maxCostPerRun` bounds the spend. Both are declared, so "the agent ran away" is a violated contract, not a surprise. `fx.run(support, { message })` uses the same tool loop and the same `fx.call` dispatch as `fx.ask(…, { tools })`.
161
+ `maxSteps` bounds the loop; `budget.maxCostPerRun` is enforced after the run. Both are declared, so "the agent ran away" is a violated contract, not a surprise. `fx.run(support, { message })` uses the same tool loop and the same `fx.call` dispatch as `fx.ask(…, { tools })`.
143
162
 
144
163
  ## Rate limits via Gate
145
164
 
@@ -157,7 +176,20 @@ Use `keyBy: "ip"` on public unauthenticated AI edges. Cost caps stay on prompt/a
157
176
 
158
177
  ## Streaming
159
178
 
160
- `fx.stream(model, { prompt })` yields real provider tokens (Ollama NDJSON, OpenAI-compatible SSE). Cancel by aborting the ambient signal — the same one `fx.all` / `fx.race` already use. Drivers without `stream` fail loud (no stub echo).
179
+ `fx.stream(model, { prompt, via? })` yields real provider tokens (Ollama NDJSON, OpenAI-compatible SSE). Cancel by aborting the ambient signal — the same one `fx.all` / `fx.race` already use. Drivers without `stream` fail loud (no stub echo).
180
+
181
+ Return those chunks on HTTP with `fx.json.stream(...)`. The kernel answers `text/event-stream` (`data:` frames, then `data: [DONE]`). JSON flows stay buffered `{ data, error }`.
182
+
183
+ ```typescript
184
+ on(
185
+ http.post("/complete").gate.public,
186
+ flow("chat.complete", {
187
+ do: (input, fx) => fx.json.stream(fx.stream(smart, { prompt: input.prompt })),
188
+ }),
189
+ );
190
+ ```
191
+
192
+ Disconnect cancels the provider call automatically (`request.signal` → ambient abort). An `AbortError` from hang-up does not retry or advance `via`. Console Traces stay empty until the stream closes — then the run is one completed row whose duration covers the open stream, not just time-to-first-byte.
161
193
 
162
194
  ## PII cannot leak by accident
163
195
 
@@ -305,6 +337,11 @@ Capability pins and Call API pass `name@version`. `fx.ask` resolves the bare
305
337
  prompt id. A pin that does not match `prompt.version` throws
306
338
  `ai: unknown prompt "name@version"`.
307
339
 
340
+ </Accordion>
341
+ <Accordion title="OKE1007 when the model calls an MCP tool">
342
+
343
+ Add `mcp:<server>/<tool>` to that flow’s `effects.calls` — the same token as `fx.call`. The allowlist on `ai.mcpServer` is not a capability grant.
344
+
308
345
  </Accordion>
309
346
  <Accordion title="An agent looped and burned budget">
310
347
 
@@ -313,6 +350,15 @@ Bound it at declaration: `maxSteps` caps iterations, `budget.maxCostPerRun` caps
313
350
  `fx.ask` stamps `promptVersion` on the run. Cost is recorded only when the
314
351
  driver reports `usage.cost` — tokens land on the ask journal either way.
315
352
 
353
+ </Accordion>
354
+ <Accordion title="The client hung up but the model kept generating">
355
+
356
+ HTTP flows install `request.signal` as the ambient abort. `fx.ask` / `fx.stream` /
357
+ `fx.run` cancel the provider fetch. Return tokens with `fx.json.stream(fx.stream(...))`
358
+ so disconnect is observable.
359
+
360
+ A hang-up is `AbortError` — it does not retry or advance `via`.
361
+
316
362
  </Accordion>
317
363
  </Accordions>
318
364
 
@@ -320,6 +366,7 @@ driver reports `usage.cost` — tokens land on the ask journal either way.
320
366
 
321
367
  - [Flow](/docs/elements/flow) — `fx.ask` and `fx.search` inside `do`
322
368
  - [Store](/docs/elements/store) — `store.index`, the home of embeddings
369
+ - [MCP](/docs/ai/mcp) — serve the app to agents, or consume external servers as tools
323
370
 
324
371
  ## Next
325
372
 
@@ -28,7 +28,7 @@ export const purgeOld = on(
28
28
  every("1h"),
29
29
  flow("links.purgeOld", {
30
30
  do: async (_, fx) => {
31
- const cutoff = fx.clock.now() - 30 * 24 * 60 * 60 * 1000; // 30 days
31
+ const cutoff = fx.clock.ago("30d");
32
32
  await fx.store(db).delete(links).where(lt(links.createdAt, cutoff));
33
33
  },
34
34
  }),
@@ -68,12 +68,15 @@ export const sendDaily = on(
68
68
  <Step>
69
69
  ### Ask for time inside flows
70
70
 
71
- `fx.clock` is the only clock a flow knows:
71
+ `fx.clock` is the only clock a flow knows. Instants stay epoch-ms; spans use the same
72
+ duration strings as `every()` and `sleep()`:
72
73
 
73
74
  ```typescript
74
75
  do: async (input, fx) => {
75
- const now = fx.clock.now(); // epoch-ms, injectable
76
- await fx.clock.sleep("wait-for-payment", "7d"); // durable — survives restarts on a shared journal
76
+ const cutoff = fx.clock.ago("30d");
77
+ const expiresAt = fx.clock.fromNow("14d");
78
+ const window = input.createdAt + fx.clock.duration("7d");
79
+ await fx.clock.sleep("wait-for-payment", "7d");
77
80
  };
78
81
  ```
79
82
 
@@ -90,13 +93,25 @@ difference.
90
93
 
91
94
  ### `clock()` options
92
95
 
93
- | Option | Type | Default | Meaning |
94
- | ------------- | ------- | ------- | -------------------------------------------------------------- |
95
- | `cron` | string | — | Cron expression `m h dom mon dow` (this or `every` required) |
96
- | `every` | string | — | Fixed interval: `"30s"` · `"10m"` · `"1h"` · `"7d"` |
97
- | `timezone` | string | `"UTC"` | IANA timezone for cron evaluation |
98
- | `overridable` | boolean | `false` | Allow the Console to **edit** the schedule (pause is separate) |
99
- | `description` | string | — | Human title in the Console (falls back to the clock name) |
96
+ | Option | Type | Default | Meaning |
97
+ | ------------- | ------- | ------- | ------------------------------------------------------------------------------------------------------------------------ |
98
+ | `cron` | string | — | Five-field crontab (`m h dom mon dow`), plus steps, ranges, lists, names, `@hourly`, `@daily` (this or `every` required) |
99
+ | `every` | string | — | Fixed interval: `"30s"` · `"10m"` · `"1h"` · `"7d"` |
100
+ | `timezone` | string | `"UTC"` | IANA timezone for cron evaluation |
101
+ | `overridable` | boolean | `false` | Allow the Console to **edit** the schedule (pause is separate) |
102
+ | `description` | string | — | Human title in the Console (falls back to the clock name) |
103
+
104
+ ### `fx.clock`
105
+
106
+ | Call | Returns | Meaning |
107
+ | -------------------- | ------- | ------------------------------------------------ |
108
+ | `now()` | instant | Injected epoch-ms |
109
+ | `ago("30d")` | instant | now − duration |
110
+ | `fromNow("14d")` | instant | now + duration |
111
+ | `duration("7d")` | span ms | Offset a stored instant (`createdAt + duration`) |
112
+ | `sleep(label, "7d")` | — | Durable sleep |
113
+
114
+ Same duration strings as `every()`: `"200ms"` · `"30s"` · `"2m"` · `"1h"` · `"7d"`. A `"d"` is 86_400_000 ms, not a calendar day. Unknown strings parse as `0`.
100
115
 
101
116
  ## Sleeping inside a flow
102
117
 
@@ -136,7 +151,7 @@ Simple daily crons (`M H * * *`) in a DST-observing zone can hit a **gap** (spri
136
151
 
137
152
  Detection attaches `dstAmbiguity` on the cron row and the Console shows it. `oke doctor` does not check DST. The scheduler does not rewrite the expression.
138
153
 
139
- **Consequence:** fall-back days can list two civil instants an hour apart. The default leader lease (30s) is shorter than that gap, so a lease alone is not a “fire once on overlap” policy. Prefer UTC, or a wall time outside the transition window, when double-fire would hurt.
154
+ **Consequence:** firing follows crontab. A spring gap runs at the next valid instant (shifted). A fall overlap fires **once** (first occurrence). Prefer UTC when a shifted spring fire would hurt.
140
155
 
141
156
  ## Per-environment drivers
142
157
 
@@ -130,7 +130,9 @@ export const findOrder = on(
130
130
 
131
131
  ### signal — another flow emits
132
132
 
133
- The producer emits through `fx` (transactionally with its writes); the consumer is the same species — no `subscribe()`, no listener registration:
133
+ The producer emits through `fx`. For `once` and `broadcast` the consumer is the same species —
134
+ `on(signal, flow)`, no `subscribe()` registration. `live` replay is the exception: `bus.live()` on
135
+ the server, not a Flow.
134
136
 
135
137
  ```typescript
136
138
  await fx.emit(orderPlaced, { orderId: id }); // inside the producing flow
@@ -154,7 +156,7 @@ on(
154
156
  every("1h"),
155
157
  flow("sessions.sweepExpired", {
156
158
  do: async (_, fx) => {
157
- const cutoff = fx.clock.now() - 30 * 24 * 60 * 60 * 1000;
159
+ const cutoff = fx.clock.ago("30d");
158
160
  await fx.store(db).delete(sessions).where(lt(sessions.createdAt, cutoff));
159
161
  },
160
162
  }),
@@ -198,12 +200,13 @@ Everything a flow may touch, on one object:
198
200
  | ------------------------------------------- | --------------- | -------------------------------------------------- |
199
201
  | `fx.store(db).select/insert/…` | read / write | SQL, KV, files, index sessions |
200
202
  | `fx.emit(signal, payload)` | emit | Publish a signal (transactional with writes) |
203
+ | `fx.deadLetters(signal)` | read | Dead-lettered messages for that signal |
201
204
  | `fx.send(template, opts)` | send | Reach a human (email · SMS · …) |
202
205
  | `fx.ask(prompt, input)` | ask | Call a versioned AI prompt |
203
206
  | `fx.run(agent, input)` | ask | Run a bounded agent |
204
207
  | `fx.call(flow, input)` | call | Invoke another flow |
205
208
  | `fx.vault.get(contract)` | secret | Read a secret (`Redacted`; logs masked) |
206
- | `fx.clock.now()` / `.sleep(…)` | — | Injected time / durable sleep |
209
+ | `fx.clock.now/ago/fromNow/duration/sleep` | — | Injected instants, spans, durable sleep |
207
210
  | `fx.cache.get/set` | — | Shared cache with effect-aware invalidation |
208
211
  | `fx.step(name, fn, { undo? })` | — | Named durable step — optional LIFO undo on failure |
209
212
  | `fx.all` / `fx.race` / `fx.retry` | — | Structured concurrency + backoff retry |
@@ -71,7 +71,8 @@ export const sendConfirmation = on(
71
71
 
72
72
  </Steps>
73
73
 
74
- That's the loop: declare → `fx.emit` → `on(signal, flow)`. The runtime handles routing, retries, and dead-lettering.
74
+ That's the loop for `once` and `broadcast`: declare → `fx.emit` → `on(signal, flow)`.
75
+ `live` keeps the same emit; the listener is `bus.live()`, not a Flow.
75
76
 
76
77
  ## The three delivery physics
77
78
 
@@ -96,7 +97,7 @@ One placed order can need all three physics at once — pick a signal per job, n
96
97
 
97
98
  | Signal | Mode | Job |
98
99
  | --------------- | ----------- | ----------------------------------------------------------- |
99
- | `order-placed` | `once` | Fulfillment job — competing workers, retries, DLQ |
100
+ | `order-placed` | `once` | One job — competing workers, retries, DLQ |
100
101
  | `order-changed` | `broadcast` | Fan-out — cache invalidation **and** customer notification |
101
102
  | `order-status` | `live` | Status feed — retained history a late subscriber can replay |
102
103
 
@@ -107,7 +108,7 @@ import { z } from "zod";
107
108
  export const orderPlaced = signal("order-placed", {
108
109
  schema: z.object({ orderId: z.string(), total: z.number() }),
109
110
  delivery: "once",
110
- retries: 2,
111
+ retries: 3,
111
112
  deadLetter: true,
112
113
  });
113
114
 
@@ -126,25 +127,43 @@ export const orderStatus = signal("order-status", {
126
127
  });
127
128
  ```
128
129
 
129
- `once` two fulfillment workers compete; **exactly one** claims each message (at-least-once; make the handler idempotent):
130
+ A Flow **writes** all three tapes. `once` / `broadcast` **listen** with `on(signal, flow)`. `live` does not.
130
131
 
131
- ```typescript title="src/flows/fulfillment/on-order.ts"
132
- on(
132
+ ```typescript title="src/flows/orders/place.ts"
133
+ export const placeOrder = on(
134
+ http.post("/orders").gate.public,
135
+ flow("orders.place", {
136
+ in: z.object({ id: z.string(), total: z.number() }),
137
+ do: async (input, fx) => {
138
+ await fx.emit(orderPlaced, { orderId: input.id, total: input.total });
139
+ await fx.emit(orderChanged, { orderId: input.id, kind: "placed" });
140
+ await fx.emit(orderStatus, { orderId: input.id, status: "placed" });
141
+ await fx.emit(orderStatus, { orderId: input.id, status: "fulfilling" });
142
+ await fx.emit(orderStatus, { orderId: input.id, status: "shipped" });
143
+ },
144
+ }),
145
+ );
146
+ ```
147
+
148
+ Stacked status emits are so replay is visible in one request. A real app emits as the order moves.
149
+
150
+ `once` — the listener is `on(orderPlaced, flow)`. Two workers compete; **exactly one** claims each message (at-least-once; make the handler idempotent):
151
+
152
+ ```typescript title="src/flows/orders/send-confirmation.ts"
153
+ export const sendConfirmation = on(
133
154
  orderPlaced,
134
- flow("fulfillment.onOrder", {
135
- do: async ({ orderId }, fx) => {
136
- await fx.emit(orderChanged, { orderId, kind: "placed" });
137
- await fx.emit(orderStatus, { orderId, status: "fulfilling" });
138
- await fx.emit(orderStatus, { orderId, status: "shipped" });
155
+ flow("orders.sendConfirmation", {
156
+ do: async (input, fx) => {
157
+ await fx.send(orderConfirmed, { to: "user-1", data: input });
139
158
  },
140
159
  }),
141
160
  );
142
161
  ```
143
162
 
144
- `broadcast` — every subscriber gets its own copy of the same domain event:
163
+ `broadcast` — every `on(orderChanged, flow)` gets its own copy. Neither Flow knows the other exists:
145
164
 
146
165
  ```typescript title="src/flows/orders/side-effects.ts"
147
- on(
166
+ export const invalidateCache = on(
148
167
  orderChanged,
149
168
  flow("cache.onOrderChanged", {
150
169
  do: async ({ orderId }, fx) => {
@@ -153,7 +172,7 @@ on(
153
172
  }),
154
173
  );
155
174
 
156
- on(
175
+ export const notifyCustomer = on(
157
176
  orderChanged,
158
177
  flow("notify.onOrderChanged", {
159
178
  do: async ({ orderId }, fx) => {
@@ -163,27 +182,38 @@ on(
163
182
  );
164
183
  ```
165
184
 
166
- `live` — retain status updates and replay the **full history** to a late `bus.live()` subscriber (tracking UI, ops feed).
185
+ `live` — retain the tape. A late subscriber replays **full history** (`placed fulfilling → shipped`), then keeps going.
167
186
 
168
- Today that subscription is a **server-side** bus API. `createClient` does not yet expose SSE / WebSocket / `client.live` — see [Client](/docs/reference/client#signal-and-live-queries).
187
+ The listener is **`bus.live()`**, not `on(orderStatus, flow)`. `on(signal, flow)` does not replay retained live payloads. Today that API is server-side; `createClient` has no SSE / WebSocket / `client.live` yet — see [Client](/docs/reference/client#signal-and-live-queries).
169
188
 
170
189
  ```typescript
171
- await fx.emit(orderStatus, { orderId, status: "placed" });
190
+ const bus = app.bootResult?.signal?.bus;
191
+ if (!bus) throw new Error("signal runtime not booted");
172
192
 
173
- // Late subscriber (server / test harness) receives placed → fulfilling → shipped.
174
193
  const unsub = await bus.live("order-status", (payload) => {
175
- /* push to UI */
194
+ /* placed fulfilling → shipped */
176
195
  });
177
196
  ```
178
197
 
179
- **Why separate signals:** delivery is fixed per declaration. Competing work stays on `once`; fan-out stays on `broadcast`; the client-visible timeline stays on `live`. Switching a word later is cheap; mixing physics on one name is not.
198
+ **Why separate signals:** delivery is fixed per declaration. Competing work stays on `once`; fan-out stays on `broadcast`; the timeline stays on `live`. Switching a word later is cheap; mixing physics on one name is not.
180
199
 
181
200
  ## When delivery fails
182
201
 
183
202
  `once` signals retry automatically. Every attempt keeps a **typed failure reason**, and the full attempt history survives into the DLQ — so when a message lands there you see _why_ each attempt failed, not just that it did.
184
203
 
204
+ Query that queue from a Flow with `fx.deadLetters(signal)` — same handle as `fx.emit`. The compiler stamps `reads: ["signal:<name>"]`; another signal throws **OKE1001**.
205
+
185
206
  The **Console** (`:6533` → `/overview`) shows emit and consume on the graph, and delivery attempts in Traces — no separate broker UI to run.
186
207
 
208
+ ```typescript
209
+ on(
210
+ http.get("/notifications/failed"),
211
+ flow("notifications.failed", {
212
+ do: async (input, fx) => fx.json.withQuery(await fx.deadLetters(orderPlaced), input),
213
+ }),
214
+ );
215
+ ```
216
+
187
217
  <Callout title="Orphan emits and schema fail loudly">
188
218
  Zero subscribers throws **OKE1042** unless `optional: true`. A payload that fails the signal's
189
219
  Standard Schema throws **OKE1043** before the message is staged — same `validate()` path as Flow
@@ -225,9 +255,10 @@ Committed `once` messages survive process death. A claim sets `lockedBy` and a v
225
255
  `live` retains every delivered message and replays the **full history** to a late `bus.live()` subscriber. There is no TTL or max-count window on that retention today (the Console payload monitor shows only the newest 50 for display).
226
256
 
227
257
  <Callout title="Client subscription is not shipped yet">
228
- `delivery: "live"` means the **driver** retains and replays. `createClient` has no SSE / WebSocket
229
- / `client.live` yet poll an HTTP Flow or use `bus.live()` server-side. See [Client · Signal and
230
- live queries](/docs/reference/client#signal-and-live-queries).
258
+ `delivery: "live"` means the **driver** retains and replays. Listen with `bus.live()` on the
259
+ server — not `on(signal, flow)`. `createClient` has no SSE / WebSocket / `client.live` yet poll
260
+ an HTTP Flow. See [Client · Signal and live
261
+ queries](/docs/reference/client#signal-and-live-queries).
231
262
  </Callout>
232
263
 
233
264
  ## Orphaned signal config
@@ -258,7 +289,10 @@ Pairing an arbitrary Store insert with emit inside **one** shared SQL transactio
258
289
  <Accordions>
259
290
  <Accordion title="Emit fails with OKE1042 (no subscriber)">
260
291
 
261
- You emitted a signal that nobody currently subscribes to. Wire a consumer with `on(signal, flow)` before emit, or set `optional: true` when zero-subscriber emits are intentional.
292
+ You emitted a signal that nobody currently subscribes to. For `once` and `broadcast`, wire
293
+ `on(signal, flow)` before emit.
294
+
295
+ `live` listeners are `bus.live()`, not a Flow — set `optional: true` when they may connect later.
262
296
 
263
297
  </Accordion>
264
298
  <Accordion title="Emit fails with OKE1043 (schema)">
@@ -268,7 +302,18 @@ The payload failed the signal's Standard Schema at emit time — nothing was sta
268
302
  </Accordion>
269
303
  <Accordion title="A message keeps retrying and then disappears">
270
304
 
271
- When `attempts > retries` the message moves to the DLQ (if `deadLetter: true`) — it is not lost. Open Console → Flows, inspect the typed failure on the trace, fix the consumer, then replay.
305
+ When `attempts > retries` the message moves to the DLQ (if `deadLetter: true`) — it is not lost. List it from a Flow with `fx.deadLetters(signal)`, or open Console → Flows, inspect the typed failure, fix the consumer, then replay.
306
+
307
+ </Accordion>
308
+ <Accordion title="fx.deadLetters throws OKE1001">
309
+
310
+ The flow read a signal it did not declare. The compiler infers `reads: ["signal:<name>"]` from `fx.deadLetters(signal)`. A different handle needs its own read.
311
+
312
+ </Accordion>
313
+ <Accordion title="on(liveSignal, flow) never replays history">
314
+
315
+ `on(signal, flow)` listens for `once` and `broadcast`. `live` replay is `bus.live()` on the Signal
316
+ bus — a Flow trigger does not replay retained payloads.
272
317
 
273
318
  </Accordion>
274
319
  <Accordion title="once vs broadcast vs live — how do I choose?">
@@ -291,6 +336,7 @@ At-least-once: crash-after-claim reclaims when the lease expires, and a handler
291
336
  ## Learn more
292
337
 
293
338
  - [Flow](/docs/elements/flow) — `on(trigger, flow)` and `fx.emit`
339
+ - [fx](/docs/reference/fx) — `fx.emit` and `fx.deadLetters`
294
340
  - [Clock](/docs/elements/clock) — scheduled and delayed work
295
341
 
296
342
  ## Next
@@ -632,6 +632,26 @@ images: {
632
632
 
633
633
  Missing Redis URL fails boot loudly: `oke boot: redis driver needs REDIS_URL`.
634
634
 
635
+ ### Durable KV
636
+
637
+ Default `store.kv("sessions")` is cache-shaped — a Redis recreate drops keys.
638
+
639
+ `{ durable: true }` persists that namespace in your SQL database (`oke_kv` JSONB on
640
+ `DATABASE_URL`). Not a per-key Redis flag, not Flow `durable`, not tier-1 auto-cache.
641
+
642
+ ```typescript
643
+ export const sessions = store.kv("sessions", { description: "Session cache" });
644
+ export const ledger = store.kv("ledger", { durable: true, description: "Idempotency keys" });
645
+ ```
646
+
647
+ Gate rates and Signal stay on `REDIS_URL`. Missing `DATABASE_URL` with the postgres driver
648
+ fails boot: `oke boot: durable store.kv needs DATABASE_URL`.
649
+
650
+ <Callout title="Not per-key, not Flow durable, not auto-cache">
651
+ Durable KV is a JSONB table on the same Postgres as `store.sql`. Flow `durable` journals steps.
652
+ Tier-1 auto-cache is an in-process Map and never reads `store.kv`.
653
+ </Callout>
654
+
635
655
  Driver id stays `redis` for every image below — same `REDIS_URL`, zero Flow changes.
636
656
  Redis is the default because it is the most mature and battle-tested; Valkey and
637
657
  Dragonfly are equally legitimate opt-in pins.
@@ -757,7 +777,7 @@ images: {
757
777
 
758
778
  ### Images — `image` / `putImage`
759
779
 
760
- For photos, use Bun's built-in `Bun.Image` pipeline on the same handle (requires Bun `>=1.3.14`).
780
+ For photos, use Bun's built-in `Bun.Image` pipeline on the same handle (requires Bun `>=1.4.0`).
761
781
  `putImage` is one write that fans into several keys:
762
782
 
763
783
  <StoreFilesVariants />
@@ -6,7 +6,7 @@ icon: Download
6
6
  ---
7
7
 
8
8
  This page gets you from zero to a running app on Bun. The engine targets
9
- **Bun ≥ 1.3.14** — prefer it for install, scaffold, and `oke dev`.
9
+ **Bun ≥ 1.4.0** — prefer it for install, scaffold, and `oke dev`.
10
10
 
11
11
  <Callout title="The one rule">
12
12
  The npm package is `okengine`; the CLI binary is `oke`. Use `bun add` in a project or `bun install
@@ -20,7 +20,7 @@ This page gets you from zero to a running app on Bun. The engine targets
20
20
  <Step>
21
21
  ### Prerequisites
22
22
 
23
- - [Bun](https://bun.sh) ≥ 1.3.14 (`bun --version`)
23
+ - [Bun](https://bun.sh) ≥ 1.4.0 (`bun --version`)
24
24
  - [Docker](https://docs.docker.com/get-docker/) with a running daemon (`docker info`)
25
25
  - A terminal and a code editor
26
26
 
@@ -43,6 +43,7 @@ Managed Postgres-wire databases — set `DATABASE_URL`, driver stays `postgres`.
43
43
  ## Redis providers
44
44
 
45
45
  Managed Redis-wire caches — set `REDIS_URL`, driver stays `redis`.
46
+ `{ durable: true }` KV lives in your SQL database, not a second Redis.
46
47
 
47
48
  <Cards>
48
49
  <Card
@@ -66,9 +66,10 @@ If the host forbids unlimited memlock, the container may fail to start.
66
66
 
67
67
  ## Data and backup
68
68
 
69
- No named volume — ephemeral by default, same as Redis/Valkey. **Backup means:** do not
70
- treat this container as durable storage until you add a volume and Dragonfly persistence
71
- settings; rebuild from SQL/files, or use [Dragonfly Cloud](/docs/providers/dragonfly-cloud).
69
+ The default `store.kv` recipe declares **no named volume** — ephemeral, same as Redis/Valkey.
70
+
71
+ Keys that must survive go on `{ durable: true }` — a JSONB table on your SQL database.
72
+ Dragonfly snapshots are not involved. See [Store · Durable KV](/docs/elements/store#durable-kv).
72
73
 
73
74
  ## Production note
74
75
 
@@ -66,12 +66,11 @@ The recipe runs:
66
66
 
67
67
  ## Data and backup
68
68
 
69
- The Redis recipe declares **no named volume**. Process memory is the source of truth;
70
- a container recreate loses keys unless you add persistence yourself
71
- (`compose.override.yml` + Redis AOF/RDB).
69
+ The default `store.kv` recipe declares **no named volume**. Process memory is the source of truth;
70
+ a container recreate loses those keys.
72
71
 
73
- **Backup means (default recipe):** treat KV as a cacherebuild from SQL / files, or
74
- add an explicit volume + `SAVE`/`BGSAVE` policy before you rely on durability.
72
+ Keys that must survive go on `{ durable: true }` a JSONB table on your SQL database, not a
73
+ second Redis. See [Store · Durable KV](/docs/elements/store#durable-kv).
75
74
 
76
75
  ## Production note
77
76
 
@@ -61,9 +61,11 @@ Same knobs as Redis: `OKE_STORE_KV_MAXMEMORY`, `OKE_STORE_KV_MAXMEMORY_POLICY`.
61
61
 
62
62
  ## Data and backup
63
63
 
64
- No named volume in the recipe — same ephemeral default as [Redis](/docs/recipes/redis).
65
- **Backup means:** do not assume durability until you add a volume + persistence config;
66
- treat the default as a shared cache rebuilt from durable stores.
64
+ The default `store.kv` recipe declares **no named volume** — same ephemeral default as
65
+ [Redis](/docs/recipes/redis).
66
+
67
+ Keys that must survive go on `{ durable: true }` — a JSONB table on your SQL database.
68
+ See [Store · Durable KV](/docs/elements/store#durable-kv).
67
69
 
68
70
  ## Production note
69
71
 
@@ -162,7 +162,9 @@ images: {
162
162
 
163
163
  Omitted image keys mean no container for that role. When both `store.sql` and `pgdog` are pinned, `DATABASE_URL` points at PgDog — see [Store](/docs/elements/store#multiple-environments).
164
164
 
165
- For `store.kv`, pin Redis (default), Valkey, or Dragonfly — driver id stays `redis`. For `ai`, see [Recipes · AI](/docs/recipes#ai-local--self-hosted). For `proxy`, see [Reverse proxy](/docs/deployment/reverse-proxy).
165
+ For `store.kv`, pin Redis (default), Valkey, or Dragonfly — driver id stays `redis`.
166
+ `{ durable: true }` KV lives in SQL (`oke_kv` on `DATABASE_URL`), not a second Redis image.
167
+ For `ai`, see [Recipes · AI](/docs/recipes#ai-local--self-hosted). For `proxy`, see [Reverse proxy](/docs/deployment/reverse-proxy).
166
168
 
167
169
  ## i18n
168
170