okengine 0.19.3 → 0.19.4

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 (47) hide show
  1. package/package.json +1 -1
  2. package/site/content/docs/elements/channel/index.mdx +1 -1
  3. package/site/content/docs/elements/clock/index.mdx +53 -105
  4. package/site/content/docs/elements/clock/schedules.mdx +39 -11
  5. package/site/content/docs/elements/flow/consumers.mdx +54 -31
  6. package/site/content/docs/elements/flow/index.mdx +3 -3
  7. package/site/content/docs/elements/flow/routing.mdx +11 -15
  8. package/site/content/docs/elements/signal/broadcast.mdx +6 -1
  9. package/site/content/docs/elements/signal/index.mdx +21 -177
  10. package/site/content/docs/elements/signal/once.mdx +6 -1
  11. package/site/content/docs/reference/errors.mdx +7 -0
  12. package/site/content/docs/understand/the-architecture.mdx +2 -2
  13. package/src/cli/competitor-mention-removal.test.ts +3 -3
  14. package/src/compiler/extract.test.ts +190 -15
  15. package/src/compiler/extract.ts +47 -4
  16. package/src/compiler/flow-path.test.ts +23 -0
  17. package/src/compiler/flow-path.ts +17 -1
  18. package/src/console/ui-next/dist/assets/{access-page-BpugjHHY.js → access-page-rO2HLPab.js} +1 -1
  19. package/src/console/ui-next/dist/assets/{agent-disclosure-qnsmtJhf.js → agent-disclosure-ae8w8JLx.js} +1 -1
  20. package/src/console/ui-next/dist/assets/{cache-glyph-BcpWUM98.js → cache-glyph-C-uHdh5d.js} +1 -1
  21. package/src/console/ui-next/dist/assets/{call-pii-button-Bz5xiNmR.js → call-pii-button-BJH54w4s.js} +1 -1
  22. package/src/console/ui-next/dist/assets/{collapsible-Cuxn2WH8.js → collapsible-CaE8cs9p.js} +1 -1
  23. package/src/console/ui-next/dist/assets/{duration-tone-Bjnl3EaM.js → duration-tone-DdOQkQVK.js} +1 -1
  24. package/src/console/ui-next/dist/assets/{flows-page-DVujp-T2.js → flows-page-DWeBszQQ.js} +1 -1
  25. package/src/console/ui-next/dist/assets/{highlighted-json-Bql0qlqW.js → highlighted-json-B2gBsC4B.js} +1 -1
  26. package/src/console/ui-next/dist/assets/{http-method-BljvrfRg.js → http-method-BnMl0dJt.js} +1 -1
  27. package/src/console/ui-next/dist/assets/{index-D1vE656k.js → index-CBAP48v5.js} +3 -3
  28. package/src/console/ui-next/dist/assets/index-VxoEz295.css +2 -0
  29. package/src/console/ui-next/dist/assets/{observability-page-Bdi0bLJl.js → observability-page-DVpcJEnW.js} +1 -1
  30. package/src/console/ui-next/dist/assets/{replica-lag-CpkPfITG.js → replica-lag-BUQf2Nf6.js} +1 -1
  31. package/src/console/ui-next/dist/assets/{request-meta-C4ZVNFVt.js → request-meta-CPkGDQgT.js} +1 -1
  32. package/src/console/ui-next/dist/assets/{store-page-DAHtnesC.js → store-page-CwS_cY4V.js} +1 -1
  33. package/src/console/ui-next/dist/assets/{trace-detail-sheet-B2c9QRBw.js → trace-detail-sheet-DNX7Y2bd.js} +1 -1
  34. package/src/console/ui-next/dist/assets/{tree-expand-toggle-BP4tRC98.js → tree-expand-toggle-Bhdk27aF.js} +1 -1
  35. package/src/console/ui-next/dist/assets/{units-page-Oi6--n09.js → units-page-CsBh0XbI.js} +1 -1
  36. package/src/console/ui-next/dist/assets/{vault-page-Dt-cPkiU.js → vault-page-CsTlPtK9.js} +1 -1
  37. package/src/console/ui-next/dist/index.html +2 -2
  38. package/src/kernel/app.ts +23 -1
  39. package/src/kernel/errors-flow-name.ts +20 -4
  40. package/src/kernel/errors.registry.test.ts +4 -2
  41. package/src/kernel/flow-name.test.ts +105 -20
  42. package/src/kernel/flow.ts +3 -3
  43. package/src/kernel/on.ts +0 -5
  44. package/src/kernel/stamp-http.test.ts +2 -1
  45. package/src/kernel/stamp-http.ts +5 -7
  46. package/src/kernel/unit.ts +1 -2
  47. package/src/console/ui-next/dist/assets/index-D0zS5rKO.css +0 -2
@@ -7,12 +7,12 @@ source: "docs/spec/unified-theory.md"
7
7
 
8
8
  Signal is how your backend **moves data when the producer should not wait**. An email job, a cache bust across instances, and a browser status feed share one handle shape — only the helper changes: `signal.once`, `signal.broadcast`, or `signal.live`.
9
9
 
10
- For developers wiring async work on okengine — declare the physics, emit with `fx.emit`, bind workers with `on(signal, flow)`.
10
+ For developers wiring async work on okengine — declare the physics, emit with `fx.emit`, bind workers with `on(handle, flow("name", { do }))`.
11
11
 
12
12
  <Callout title="The one rule">
13
- Declare every signal with `signal.once`, `signal.broadcast`, or `signal.live` — same idea as
14
- `http.get` / `http.post`. Physics and the **emit** `schema` live on the Signal; the Flow
15
- worker is only `flow({ do })` — it inherits the payload type from the Signal, not `flow.in`.
13
+ Declare every signal with `signal.once`, `signal.broadcast`, or `signal.live` as an
14
+ exported const. Bind with `on(handle, flow("name", { do }))`. Physics and the **emit**
15
+ `schema` live on the Signal; the worker inherits the payload type, not `flow.in`.
16
16
  </Callout>
17
17
 
18
18
  <SignalDelivery />
@@ -77,167 +77,8 @@ for the worker.
77
77
  competing consumer.
78
78
  </Callout>
79
79
 
80
- ## Inline or named export
81
-
82
- | Style | When |
83
- | --------------------------------------------- | --------------------------------------------------------------------------- |
84
- | `on(signal.once("name", opts), flow({ do }))` | Self-contained — nothing else needs the Signal handle |
85
- | `export const x = signal.once<Payload>(…)` | Another file needs `fx.emit(x, payload)` with compile-time payload checking |
86
-
87
- Both styles stamp the same Manifest `flow.trigger`. The choice is where the
88
- declaration lives, not two runtimes.
89
-
90
80
  A string `fx.emit("name", payload)` still runs (runtime `schema` still applies) but does not
91
- type-check. What the Flow is called is a separate choice — [Flow name](#flow-name).
92
-
93
- <Tabs items={["Inline", "Named"]}>
94
-
95
- <Tab value="Inline">
96
-
97
- One file — declare and bind together. Producers emit with the string name:
98
-
99
- ```typescript title="src/flows/hooks/inbound.ts"
100
- import { on, flow, signal } from "okengine";
101
- import { z } from "zod";
102
-
103
- export const ingestWebhook = on(
104
- signal.once("hooks.inbound", {
105
- schema: z.object({ id: z.string() }),
106
- retries: 3,
107
- deadLetter: true,
108
- }),
109
- flow({
110
- do: async ({ id }, fx) => {
111
- await fx.call(persistHook, { id });
112
- },
113
- }),
114
- );
115
- ```
116
-
117
- ```typescript
118
- await fx.emit("hooks.inbound", { id: "h_1" });
119
- ```
120
-
121
- </Tab>
122
-
123
- <Tab value="Named">
124
-
125
- Export the handle when another file must `fx.emit` with compile-time payload checking:
126
-
127
- ```typescript title="src/signals/orders.ts"
128
- import { signal } from "okengine";
129
- import { z } from "zod";
130
-
131
- export const orderPlaced = signal.once("orders.placed", {
132
- schema: z.object({ orderId: z.string() }),
133
- });
134
- ```
135
-
136
- ```typescript title="src/flows/orders/fulfill.ts"
137
- import { on, flow } from "okengine";
138
- import { orderPlaced } from "@/signals/orders";
139
-
140
- export const fulfill = on(
141
- orderPlaced,
142
- flow("orders.fulfill", {
143
- do: async ({ orderId }, fx) => {
144
- await fx.call(chargeAndShip, { orderId });
145
- },
146
- }),
147
- );
148
- ```
149
-
150
- ```typescript
151
- await fx.emit(orderPlaced, { orderId: "ord_99" });
152
- ```
153
-
154
- </Tab>
155
-
156
- </Tabs>
157
-
158
- ## Flow name
159
-
160
- | Style | When |
161
- | ----------------------------- | ----------------------------------------------------------------------------- |
162
- | `flow({ do })` | No unit folder — Flow name is the Signal name (`hooks.inbound`) |
163
- | `flow("orders.fulfill")` | Manifest / `fx.call` name must differ from the Signal |
164
- | Tree `export const onCreated` | `src/flows/notes/on-created.ts` stamps `notes.onCreated` — overwrites inherit |
165
-
166
- Explicit `flow("…")` and the file tree overwrite inherit. HTTP does not inherit a
167
- name from the path — nameless HTTP stays for the tree or fails **OKE1045**. Two
168
- Flows that land on the same name fail **OKE1070** (`Flow "{flow}" is defined twice.`).
169
-
170
- <Tabs items={["Inherit", "Explicit", "Tree"]}>
171
-
172
- <Tab value="Inherit">
173
-
174
- A file directly in `src/flows/` (no unit folder) has nothing to stamp. The Flow
175
- is named `hooks.inbound` — same as the Signal:
176
-
177
- ```typescript title="src/flows/hooks.ts"
178
- import { on, flow, signal } from "okengine";
179
- import { z } from "zod";
180
-
181
- export const ingestWebhook = on(
182
- signal.once("hooks.inbound", {
183
- schema: z.object({ id: z.string() }),
184
- retries: 3,
185
- deadLetter: true,
186
- }),
187
- flow({
188
- do: async ({ id }, fx) => {
189
- await fx.call(persistHook, { id });
190
- },
191
- }),
192
- );
193
- ```
194
-
195
- </Tab>
196
-
197
- <Tab value="Explicit">
198
-
199
- The Signal stays `orders.placed`. The Flow is `orders.fulfill` — that is the
200
- Manifest / `fx.call` name:
201
-
202
- ```typescript title="src/flows/fulfill.ts"
203
- import { on, flow } from "okengine";
204
- import { orderPlaced } from "@/signals/orders";
205
-
206
- export const fulfill = on(
207
- orderPlaced,
208
- flow("orders.fulfill", {
209
- do: async ({ orderId }, fx) => {
210
- await fx.call(chargeAndShip, { orderId });
211
- },
212
- }),
213
- );
214
- ```
215
-
216
- </Tab>
217
-
218
- <Tab value="Tree">
219
-
220
- Unit folder + `export const` stamps `unit.export`. The Flow is `notes.onCreated`,
221
- not `note-created`:
222
-
223
- ```typescript title="src/flows/notes/on-created.ts"
224
- import { on, flow } from "okengine";
225
- import { noteCreatedMail } from "@/core";
226
- import { noteCreated } from "./signals";
227
-
228
- export const onCreated = on(
229
- noteCreated,
230
- flow({
231
- do: async ({ id }, fx) => {
232
- await fx.send(noteCreatedMail, { to: "you@localhost", data: { id } });
233
- },
234
- }),
235
- );
236
- ```
237
-
238
- </Tab>
239
-
240
- </Tabs>
81
+ type-check. Import the exported const.
241
82
 
242
83
  ## Progressive Patterns
243
84
 
@@ -451,14 +292,14 @@ Fix: `Use signal.broadcast if each flow should independently receive this event,
451
292
 
452
293
  Optional second argument to `signal.once` / `signal.broadcast` / `signal.live`. Delivery is the helper name — not an option.
453
294
 
454
- | Option | Type | Default | Meaning |
455
- | ------------- | ------------------------ | --------- | ------------------------------------------------------------------------------------------------------------------------------------ |
456
- | `schema` | Standard Schema | omitted | **Emit** contract — enforced at `fx.emit` (**OKE1250** on mismatch). Workers inherit the payload; do not put `in` on `flow({ do })`. |
457
- | `retries` | `number` | `3` | Extra attempts after the first (`retries + 1` total) — `once` path |
458
- | `deadLetter` | `boolean` | `true` | Keep exhausted `once` messages; `false` marks them delivered |
459
- | `optional` | `boolean` | `false` | Allow emit with zero subscribers |
460
- | `retention` | `{ maxAge?, maxCount? }` | unbounded | **`signal.live` only** — type error on `once` / `broadcast` |
461
- | `description` | `string` | the name | Console / docs blurb |
295
+ | Option | Type | Default | Meaning |
296
+ | ------------- | ------------------------ | --------- | ------------------------------------------------------------------------------------------------------------------------------ |
297
+ | `schema` | Standard Schema | omitted | **Emit** contract — enforced at `fx.emit` (**OKE1250** on mismatch). Workers inherit the payload; do not put `in` on `flow()`. |
298
+ | `retries` | `number` | `3` | Extra attempts after the first (`retries + 1` total) — `once` path |
299
+ | `deadLetter` | `boolean` | `true` | Keep exhausted `once` messages; `false` marks them delivered |
300
+ | `optional` | `boolean` | `false` | Allow emit with zero subscribers |
301
+ | `retention` | `{ maxAge?, maxCount? }` | unbounded | **`signal.live` only** — type error on `once` / `broadcast` |
302
+ | `description` | `string` | the name | Console / docs blurb |
462
303
 
463
304
  **Consequence:** `deadLetter` is a boolean flag, not a queue name string.
464
305
 
@@ -527,9 +368,13 @@ export default defineConfig({
527
368
  </Accordion>
528
369
 
529
370
  <Accordion title="OKE1070 — flow name defined twice">
530
- Cause: `Flow "{flow}" is defined twice.` Two nameless consumers inherited the same Signal name, or
531
- two explicit `flow("…")` calls collide. Give at least one a distinct `flow("…")` or tree export —
532
- [Flow name](#flow-name).
371
+ Cause: `Flow "{flow}" is defined twice.` Two `flow("…")` strings collide. Give at least one a
372
+ distinct name.
373
+ </Accordion>
374
+
375
+ <Accordion title="OKE1072 — Signal flow unnamed">
376
+ Cause: `A signal flow on "{trigger}" has no name.`
377
+ Fix: pass an explicit name — `on(handle, flow("orders.fulfill", { do }))`.
533
378
  </Accordion>
534
379
 
535
380
  <Accordion title="OKE1071 — once signal bound to more than one Flow">
@@ -557,11 +402,10 @@ export default defineConfig({
557
402
  - [Broadcast](/docs/elements/signal/broadcast) — ephemeral fan-out
558
403
  - [Live](/docs/elements/signal/live) — SSE tapes and retention
559
404
  - [Consumers](/docs/elements/flow/consumers) — `on(signal)` workers next to Clock / CDC
560
- - [Routing](/docs/elements/flow/routing#names) — tree `unit.export` vs inherit
561
405
  - [HTTP · Live Streams](/docs/elements/flow/http#live-streams) — `http.live` exposure
562
406
  - [fx](/docs/reference/fx) — `fx.emit`, `fx.deadLetters`, `fx.live`
563
407
  - [Client](/docs/client/live) — `api.live` for browsers
564
- - [Errors](/docs/reference/errors) — OKE1070 · OKE1071 · OKE1240 · OKE1250 · OKE1210
408
+ - [Errors](/docs/reference/errors) — OKE1070 · OKE1071 · OKE1072 · OKE1240 · OKE1250 · OKE1210
565
409
 
566
410
  ## Next
567
411
 
@@ -591,6 +591,11 @@ See [Durable Workflows](/docs/elements/flow/workflows).
591
591
  are still one `on()` in source.
592
592
  </Accordion>
593
593
 
594
+ <Accordion title="OKE1072 — Signal flow unnamed">
595
+ Cause: `A signal flow on "{trigger}" has no name.`
596
+ Fix: pass an explicit name — `on(handle, flow("workers.email", { do }))`.
597
+ </Accordion>
598
+
594
599
  <Accordion title="Messages stuck inflight">
595
600
  Wait for the 30s lease and the next drain/claim. There is no separate timeout daemon. A handler
596
601
  still running past the lease can overlap with a reclaim — shorten the work or journal it.
@@ -629,7 +634,7 @@ See [Durable Workflows](/docs/elements/flow/workflows).
629
634
  - [Consumers](/docs/elements/flow/consumers) — `on(signal)` next to Clock / CDC
630
635
  - [Workflows](/docs/elements/flow/workflows) — `durable` + `fx.step` for idempotent side effects
631
636
  - [fx](/docs/reference/fx) — `fx.emit`, `fx.deadLetters`
632
- - [Errors](/docs/reference/errors) — OKE1071 · OKE1240 · OKE1250 · OKE1001
637
+ - [Errors](/docs/reference/errors) — OKE1071 · OKE1072 · OKE1240 · OKE1250 · OKE1001
633
638
 
634
639
  ## Next
635
640
 
@@ -98,6 +98,7 @@ string. Custom app codes stay message-less until registered. Full catalogs:
98
98
  | `1060` | MCP tool duplicate | Two MCP tool bindings share the same tool name | Give each MCP tool exposure a unique name |
99
99
  | `1070` | flow name duplicate | Two Flows share the same Manifest / `fx.call` name | Give at least one an explicit `flow("…")` or tree export |
100
100
  | `1071` | once-signal multi-flow | Two different Flows bound to the same `signal.once` | Use `signal.broadcast`, or bind only one Flow |
101
+ | `1072` | flow unnamed | Signal / Clock consumer still has no `unit.export` | Export from `flows/<unit>/` or pass a named `flow` |
101
102
  | `1110` | schema missing | Domain table absent in `prod` — no auto-DDL | Run `oke db migrate` against this environment |
102
103
  | `1210` | live resume gap | `Last-Event-ID` is not on the retained tape | Reconnect without the cursor; remaining tape replays |
103
104
  | `1240` | orphan emit | Emit with zero subscribers and `optional` false | Add `on(signal, …)` or declare `optional: true` |
@@ -173,6 +174,12 @@ Thrown by specific subsystems — each names its own cause:
173
174
  A resource mount and a handwritten route share the same method + path. Drop one binding.
174
175
  </Accordion>
175
176
 
177
+ <Accordion title="OKE1072 Signal or Clock flow unnamed">
178
+ Cause: `A {kind} flow on "{trigger}" has no name.`
179
+ Fix: pass an explicit name — `on(handle, flow("unit.export", { do }))`. See
180
+ [Signal](/docs/elements/signal) · [Clock](/docs/elements/clock).
181
+ </Accordion>
182
+
176
183
  <Accordion title="OKE1071 once signal bound to more than one Flow">
177
184
  Cause: `Once signal "{signal}" is bound to more than one Flow ({flows}).` Use `signal.broadcast`
178
185
  if each Flow should get a copy, or bind only one Flow. See [Once · Competing
@@ -168,8 +168,8 @@ flow({
168
168
  ```
169
169
 
170
170
  Omit the name on tree files — the compiler stamps `unit.export` (e.g. `users.signup`).
171
- Pass `flow("users.signup", { … })` only for control: barrels, stable names across
172
- moves, or call-only Flows you `fx.call` by name.
171
+ Pass `flow("users.signup", { … })` outside a unit folder, or for barrels / `fx.call`.
172
+ Nameless Signal / Clock consumers outside a unit fail **OKE1072**.
173
173
 
174
174
  ### `do` — the code that actually runs
175
175
 
@@ -45,9 +45,9 @@ const EXPRESS_ALLOW = ["src/plugins/headers.test.ts:"];
45
45
  const OKID_PROMPT_ALLOW = [
46
46
  "okid.md:",
47
47
  "bun.lock:",
48
- // Minified zod-core inside the vendored Console bundle registers string
49
- // format validators whose names collide with peer library names — vendor
50
- // identifiers, not authored comparisons. Hash changes on Console rebuilds.
48
+ // Minified zod-core inside a locally built Console bundle (gitignored)
49
+ // registers string-format validators whose names collide with peer library
50
+ // names — vendor identifiers, not authored comparisons.
51
51
  "src/console/ui-next/dist/",
52
52
  ];
53
53
 
@@ -2069,21 +2069,21 @@ export const app = oke({
2069
2069
  });
2070
2070
  });
2071
2071
 
2072
- describe("extractManifest — inline Signal/Clock + name inheritance", () => {
2072
+ describe("extractManifest — inline Signal/Clock + explicit / tree names", () => {
2073
2073
  test("inline signal.once / clock.every stamp element maps and flow.trigger", async () => {
2074
2074
  const source = `
2075
2075
  import { on, flow, signal, clock } from "okengine";
2076
2076
 
2077
2077
  on(
2078
2078
  signal.once("link-clicked", { retries: 3, deadLetter: true }),
2079
- flow({ do: async (input, fx) => {
2079
+ flow("hooks.linkClicked", { do: async (input, fx) => {
2080
2080
  await fx.store("sql:links").set("x", input);
2081
2081
  } }),
2082
2082
  );
2083
2083
 
2084
2084
  on(
2085
2085
  clock.every("cleanup", "10m"),
2086
- flow({ do: async (_input, fx) => {
2086
+ flow("ops.cleanup", { do: async (_input, fx) => {
2087
2087
  await fx.store("sql:links").set("y", 1);
2088
2088
  } }),
2089
2089
  );
@@ -2095,16 +2095,16 @@ on(
2095
2095
  deadLetter: true,
2096
2096
  });
2097
2097
  expect(manifest.clocks?.cleanup).toMatchObject({ every: "10m" });
2098
- expect(manifest.flows?.["link-clicked"]?.trigger).toEqual({ signal: "link-clicked" });
2099
- expect(manifest.flows?.cleanup?.trigger).toEqual({ every: "10m" });
2098
+ expect(manifest.flows?.["hooks.linkClicked"]?.trigger).toEqual({ signal: "link-clicked" });
2099
+ expect(manifest.flows?.["ops.cleanup"]?.trigger).toEqual({ every: "10m" });
2100
2100
  });
2101
2101
 
2102
- test("nameless flow inherits the named trigger; explicit flow name wins", async () => {
2102
+ test("explicit flow name is independent of the trigger name", async () => {
2103
2103
  const source = `
2104
2104
  import { on, flow, signal, clock } from "okengine";
2105
2105
 
2106
2106
  export const orderPlaced = signal.once("order-placed");
2107
- on(orderPlaced, flow({ do: () => ({ ok: true }) }));
2107
+ on(orderPlaced, flow("orders.fulfill", { do: () => ({ ok: true }) }));
2108
2108
 
2109
2109
  on(
2110
2110
  clock.every("metrics.cleanup", "1h"),
@@ -2112,12 +2112,13 @@ on(
2112
2112
  );
2113
2113
  `;
2114
2114
  const manifest = await extractFromSources({ "workers.ts": source });
2115
- expect(manifest.flows?.["order-placed"]?.trigger).toEqual({ signal: "order-placed" });
2115
+ expect(manifest.flows?.["orders.fulfill"]?.trigger).toEqual({ signal: "order-placed" });
2116
2116
  expect(manifest.flows?.["ops.sweep"]?.trigger).toEqual({ every: "1h" });
2117
2117
  expect(manifest.flows?.["metrics.cleanup"]).toBeUndefined();
2118
+ expect(manifest.flows?.["order-placed"]).toBeUndefined();
2118
2119
  });
2119
2120
 
2120
- test("file-tree unit.export wins over trigger-name inheritance", async () => {
2121
+ test("file-tree unit.export stamps a nameless Signal consumer", async () => {
2121
2122
  const source = `
2122
2123
  import { on, flow, signal } from "okengine";
2123
2124
  export const onCreated = on(
@@ -2132,15 +2133,189 @@ export const onCreated = on(
2132
2133
  expect(manifest.flows?.["note-created"]).toBeUndefined();
2133
2134
  });
2134
2135
 
2135
- test("two nameless inheritances of the same trigger name fail extract", async () => {
2136
+ test("two differently-named flows on the same clock extract cleanly", async () => {
2137
+ const source = `
2138
+ import { on, flow, clock } from "okengine";
2139
+ export const tick = clock.every("metrics.tick", "1h");
2140
+ on(tick, flow("ops.sweep", { do: () => ({ a: true }) }));
2141
+ on(tick, flow("ops.report", { do: () => ({ b: true }) }));
2142
+ `;
2143
+ const manifest = await extractFromSources({ "clock-fanout.ts": source });
2144
+ expect(manifest.clocks?.["metrics.tick"]).toMatchObject({ every: "1h" });
2145
+ expect(manifest.flows?.["ops.sweep"]?.trigger).toEqual({ every: "1h" });
2146
+ expect(manifest.flows?.["ops.report"]?.trigger).toEqual({ every: "1h" });
2147
+ });
2148
+
2149
+ // Exact Clock docs Inline sample — explicit flow name, trigger written inline.
2150
+ const clockInlineExplicitDocsSample = `
2151
+ import { on, flow, clock } from "okengine";
2152
+
2153
+ export const pingExternal = on(
2154
+ clock.every("health.pingExternal", "30s"),
2155
+ flow("health.pingExternal", {
2156
+ plane: "operator",
2157
+ do: async (_, fx) => {
2158
+ await fx.call(pingUpstream);
2159
+ },
2160
+ }),
2161
+ );
2162
+ `;
2163
+
2164
+ test("docs Clock Inline (explicit name) extracts outside src/flows/<unit>/", async () => {
2165
+ const manifest = await extractFromSources({ "ping.ts": clockInlineExplicitDocsSample });
2166
+ expect(manifest.clocks?.["health.pingExternal"]?.every).toBe("30s");
2167
+ expect(manifest.flows?.["health.pingExternal"]?.trigger).toEqual({ every: "30s" });
2168
+ expect(manifest.flows?.["pingExternal"]).toBeUndefined();
2169
+ });
2170
+
2171
+ test("docs Clock named-export (shared schedule) extracts two explicit flows", async () => {
2172
+ const clockSource = `
2173
+ import { clock } from "okengine";
2174
+
2175
+ export const tickClock = clock.every("metrics.tick", "1h");
2176
+ `;
2177
+ const flowsSource = `
2178
+ import { on, flow } from "okengine";
2179
+ import { tickClock } from "@/clocks/metrics";
2180
+
2181
+ export const sweep = on(
2182
+ tickClock,
2183
+ flow("ops.sweep", {
2184
+ plane: "operator",
2185
+ do: async (_, fx) => {
2186
+ await fx.call(sweepMetrics);
2187
+ },
2188
+ }),
2189
+ );
2190
+
2191
+ export const report = on(
2192
+ tickClock,
2193
+ flow("ops.report", {
2194
+ plane: "operator",
2195
+ do: async (_, fx) => {
2196
+ await fx.call(reportMetrics);
2197
+ },
2198
+ }),
2199
+ );
2200
+ `;
2201
+ const manifest = await extractFromSources({
2202
+ "src/clocks/metrics.ts": clockSource,
2203
+ "src/flows/ops/metrics.ts": flowsSource,
2204
+ });
2205
+ expect(manifest.clocks?.["metrics.tick"]).toMatchObject({ every: "1h" });
2206
+ expect(manifest.flows?.["ops.sweep"]?.trigger).toEqual({ every: "1h" });
2207
+ expect(manifest.flows?.["ops.report"]?.trigger).toEqual({ every: "1h" });
2208
+ });
2209
+ });
2210
+
2211
+ describe("extractManifest — nameless Signal/Clock consumers fail OKE1072", () => {
2212
+ test("nameless flow({ do }) on signal.once fails OKE1072", async () => {
2213
+ const source = `
2214
+ import { on, flow, signal } from "okengine";
2215
+ on(signal.once("link-clicked"), flow({ do: () => ({ ok: true }) }));
2216
+ `;
2217
+ await expect(extractFromSources({ "once.ts": source })).rejects.toThrow(/OKE1072/);
2218
+ });
2219
+
2220
+ test("nameless flow({ do }) on signal.broadcast fails OKE1072", async () => {
2221
+ const source = `
2222
+ import { on, flow, signal } from "okengine";
2223
+ on(signal.broadcast("catalog.changed"), flow({ do: () => ({ ok: true }) }));
2224
+ `;
2225
+ await expect(extractFromSources({ "broadcast.ts": source })).rejects.toThrow(/OKE1072/);
2226
+ });
2227
+
2228
+ test("nameless flow({ do }) on signal.live fails OKE1072", async () => {
2136
2229
  const source = `
2137
2230
  import { on, flow, signal } from "okengine";
2138
- const ping = signal.once("health.ping");
2139
- on(ping, flow({ do: () => ({ a: true }) }));
2140
- on(ping, flow({ do: () => ({ b: true }) }));
2231
+ on(signal.live("order-status", { optional: true }), flow({ do: () => ({ ok: true }) }));
2232
+ `;
2233
+ await expect(extractFromSources({ "live.ts": source })).rejects.toThrow(/OKE1072/);
2234
+ });
2235
+
2236
+ test("nameless flow({ do }) on clock.every fails OKE1072", async () => {
2237
+ const source = `
2238
+ import { on, flow, clock } from "okengine";
2239
+ on(clock.every("cleanup", "10m"), flow({ do: () => ({ ok: true }) }));
2141
2240
  `;
2142
- await expect(extractFromSources({ "dup.ts": source })).rejects.toThrow(
2143
- /duplicate flow name "health.ping"/,
2241
+ await expect(extractFromSources({ "clock.ts": source })).rejects.toThrow(/OKE1072/);
2242
+ });
2243
+
2244
+ // Exact Signal docs Inline sample — tree path vs anywhere else.
2245
+ const signalInlineDocsSample = `
2246
+ import { on, flow, signal } from "okengine";
2247
+ import { z } from "zod";
2248
+
2249
+ export const ingestWebhook = on(
2250
+ signal.once("hooks.inbound", {
2251
+ schema: z.object({ id: z.string() }),
2252
+ retries: 3,
2253
+ deadLetter: true,
2254
+ }),
2255
+ flow({
2256
+ do: async ({ id }, fx) => {
2257
+ await fx.call(persistHook, { id });
2258
+ },
2259
+ }),
2260
+ );
2261
+ `;
2262
+
2263
+ test("docs Inline sample at src/flows/hooks/inbound.ts stamps hooks.ingestWebhook", async () => {
2264
+ const manifest = await extractFromSources({
2265
+ "src/flows/hooks/inbound.ts": signalInlineDocsSample,
2266
+ });
2267
+ expect(manifest.flows?.["hooks.ingestWebhook"]?.trigger).toEqual({ signal: "hooks.inbound" });
2268
+ expect(manifest.signals?.["hooks.inbound"]?.delivery).toBe("once");
2269
+ expect(manifest.flows?.["ingestWebhook"]).toBeUndefined();
2270
+ expect(manifest.flows?.["hooks.inbound"]).toBeUndefined();
2271
+ });
2272
+
2273
+ test("docs Inline sample outside src/flows/<unit>/ fails OKE1072", async () => {
2274
+ try {
2275
+ await extractFromSources({ "inbound.ts": signalInlineDocsSample });
2276
+ expect.unreachable("extract should throw OKE1072");
2277
+ } catch (err) {
2278
+ expect(err).toBeInstanceOf(Error);
2279
+ const message = (err as Error).message;
2280
+ expect(message).toMatch(/OKE1072/);
2281
+ expect(message).toContain("hooks.inbound");
2282
+ expect(message).toContain('flow("unit.export"');
2283
+ expect(message).toContain("src/flows/<unit>/");
2284
+ }
2285
+ });
2286
+
2287
+ test("docs Inline sample under src/signals/ fails OKE1072", async () => {
2288
+ await expect(
2289
+ extractFromSources({ "src/signals/inbound.ts": signalInlineDocsSample }),
2290
+ ).rejects.toThrow(/OKE1072/);
2291
+ });
2292
+
2293
+ const clockInlineDocsSample = `
2294
+ import { on, flow, clock } from "okengine";
2295
+
2296
+ export const pingExternal = on(
2297
+ clock.every("health.pingExternal", "30s"),
2298
+ flow({
2299
+ plane: "operator",
2300
+ do: async (_, fx) => {
2301
+ await fx.call(pingUpstream);
2302
+ },
2303
+ }),
2304
+ );
2305
+ `;
2306
+
2307
+ test("docs Clock Inline sample at src/flows/health/ping.ts stamps health.pingExternal", async () => {
2308
+ const manifest = await extractFromSources({
2309
+ "src/flows/health/ping.ts": clockInlineDocsSample,
2310
+ });
2311
+ expect(manifest.flows?.["health.pingExternal"]?.trigger).toEqual({ every: "30s" });
2312
+ expect(manifest.clocks?.["health.pingExternal"]?.every).toBe("30s");
2313
+ expect(manifest.flows?.["pingExternal"]).toBeUndefined();
2314
+ });
2315
+
2316
+ test("docs Clock Inline sample outside src/flows/<unit>/ fails OKE1072", async () => {
2317
+ await expect(extractFromSources({ "ping.ts": clockInlineDocsSample })).rejects.toThrow(
2318
+ /OKE1072/,
2144
2319
  );
2145
2320
  });
2146
2321
  });
@@ -55,7 +55,7 @@ import {
55
55
  type InferBinding,
56
56
  type Literal,
57
57
  } from "./effects-infer.ts";
58
- import { nameFromFlowFile, pathFromFlowFile } from "./flow-path.ts";
58
+ import { isFlowsTreeFile, nameFromFlowFile, pathFromFlowFile } from "./flow-path.ts";
59
59
  import {
60
60
  defaultListInSchema,
61
61
  jsonSchemaFromAst,
@@ -1852,13 +1852,21 @@ function registerFlow(args: {
1852
1852
  const trigger = args.triggerNode
1853
1853
  ? parseTrigger(args.triggerNode, args.scope, args.file.path)
1854
1854
  : undefined;
1855
- const inherited = elementTriggerName(args.triggerNode, args.scope);
1856
1855
 
1856
+ const signalOrClock = isSignalOrClockTrigger(trigger);
1857
+ const treeName =
1858
+ !signalOrClock || isFlowsTreeFile(args.file.path)
1859
+ ? nameFromFlowFile(args.file.path, args.exportName)
1860
+ : undefined;
1861
+
1862
+ // Signal / Clock: explicit `flow("name")` or a real `src/flows/<unit>/`
1863
+ // tree stamp. A bare `export const` outside that folder is not a name —
1864
+ // **OKE1072**, same as `oke()`. HTTP still falls through to exportName.
1857
1865
  const name =
1858
1866
  stringArg(args.flowCall.arguments[0]) ??
1859
- nameFromFlowFile(args.file.path, args.exportName) ??
1867
+ treeName ??
1868
+ (signalOrClock ? unnamedElementFlowName(args.triggerNode, args.scope, trigger) : undefined) ??
1860
1869
  args.exportName ??
1861
- inherited ??
1862
1870
  `flow_${Object.keys(args.scope.flows).length + 1}`;
1863
1871
 
1864
1872
  if (args.exportName) {
@@ -2414,10 +2422,22 @@ function assertOnceSignalSingleFlow(scope: ProjectScope): void {
2414
2422
  }
2415
2423
  }
2416
2424
 
2425
+ /**
2426
+ * True when the parsed trigger is a Signal or Clock (cron / every).
2427
+ *
2428
+ * @param parsed - Parsed `on()` trigger
2429
+ */
2430
+ function isSignalOrClockTrigger(parsed: ParsedTrigger | undefined): boolean {
2431
+ const t = parsed?.trigger;
2432
+ return Boolean(t && (t.signal || t.cron || t.every));
2433
+ }
2434
+
2417
2435
  /**
2418
2436
  * Stable Signal / Clock name on `on()`'s first argument, when one exists.
2419
2437
  * HTTP has no inherent name of this kind — returns undefined.
2420
2438
  *
2439
+ * Used only for **OKE1072** diagnostics — never as a Flow name.
2440
+ *
2421
2441
  * @param node - Trigger AST
2422
2442
  * @param scope - Project scope
2423
2443
  */
@@ -2450,6 +2470,29 @@ function elementTriggerName(node: AstNode | undefined, scope: ProjectScope): str
2450
2470
  return undefined;
2451
2471
  }
2452
2472
 
2473
+ /**
2474
+ * Fail loud when a Signal / Clock consumer has no explicit or tree-derived name.
2475
+ * HTTP still falls through to the generic `flow_*` placeholder (OKE1045 at boot).
2476
+ *
2477
+ * @param triggerNode - Trigger AST
2478
+ * @param scope - Project scope
2479
+ * @param parsed - Parsed trigger
2480
+ * @returns Never for Signal / Clock; `undefined` otherwise
2481
+ */
2482
+ function unnamedElementFlowName(
2483
+ triggerNode: AstNode | undefined,
2484
+ scope: ProjectScope,
2485
+ parsed: ParsedTrigger | undefined,
2486
+ ): undefined {
2487
+ if (!isSignalOrClockTrigger(parsed)) return undefined;
2488
+ const triggerName = elementTriggerName(triggerNode, scope);
2489
+ const kind = parsed?.trigger?.signal ? "signal" : "clock";
2490
+ const target = triggerName ? `${kind} "${triggerName}"` : kind;
2491
+ throw new Error(
2492
+ `OKE1072: nameless flow({ do }) bound to ${target}. Use flow("unit.export", { do }) or export it from a src/flows/<unit>/ file so the tree can stamp unit.export.`,
2493
+ );
2494
+ }
2495
+
2453
2496
  /**
2454
2497
  * Inline `signal.once(…)` / `clock.every(…)` as `on()`'s first argument.
2455
2498
  *