okengine 0.19.3 → 0.19.5

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 (57) 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 +6 -9
  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/cli.mdx +14 -1
  12. package/site/content/docs/reference/errors.mdx +7 -0
  13. package/site/content/docs/understand/the-architecture.mdx +2 -2
  14. package/site/content/docs/understand/try-it.mdx +1 -1
  15. package/src/cli/competitor-mention-removal.test.ts +3 -3
  16. package/src/cli/dev-app-runner.ts +23 -2
  17. package/src/cli/dev.test.ts +47 -0
  18. package/src/cli/dev.ts +11 -0
  19. package/src/compiler/extract-skip.test.ts +43 -0
  20. package/src/compiler/extract.test.ts +190 -15
  21. package/src/compiler/extract.ts +57 -7
  22. package/src/compiler/flow-path.test.ts +36 -0
  23. package/src/compiler/flow-path.ts +35 -1
  24. package/src/compiler/generate-adopt.ts +12 -2
  25. package/src/console/ui-next/dist/assets/{access-page-BpugjHHY.js → access-page-BDbw40sZ.js} +1 -1
  26. package/src/console/ui-next/dist/assets/{agent-disclosure-qnsmtJhf.js → agent-disclosure-CVhs5xog.js} +1 -1
  27. package/src/console/ui-next/dist/assets/{cache-glyph-BcpWUM98.js → cache-glyph-CKIaJ1te.js} +1 -1
  28. package/src/console/ui-next/dist/assets/{call-pii-button-Bz5xiNmR.js → call-pii-button-CPH68-4L.js} +1 -1
  29. package/src/console/ui-next/dist/assets/{collapsible-Cuxn2WH8.js → collapsible-DYYX3bKy.js} +1 -1
  30. package/src/console/ui-next/dist/assets/{duration-tone-Bjnl3EaM.js → duration-tone-BnRJ32O5.js} +1 -1
  31. package/src/console/ui-next/dist/assets/{flows-page-DVujp-T2.js → flows-page-DwEDnJqd.js} +1 -1
  32. package/src/console/ui-next/dist/assets/{highlighted-json-Bql0qlqW.js → highlighted-json-BemWpaqn.js} +1 -1
  33. package/src/console/ui-next/dist/assets/{http-method-BljvrfRg.js → http-method-CAboOgAE.js} +1 -1
  34. package/src/console/ui-next/dist/assets/{index-D1vE656k.js → index-C3KoC-mS.js} +3 -3
  35. package/src/console/ui-next/dist/assets/index-VxoEz295.css +2 -0
  36. package/src/console/ui-next/dist/assets/{observability-page-Bdi0bLJl.js → observability-page-C2sNV7Qu.js} +1 -1
  37. package/src/console/ui-next/dist/assets/{replica-lag-CpkPfITG.js → replica-lag-DJQeKIw-.js} +1 -1
  38. package/src/console/ui-next/dist/assets/{request-meta-C4ZVNFVt.js → request-meta-DQ6HY-a4.js} +1 -1
  39. package/src/console/ui-next/dist/assets/{store-page-DAHtnesC.js → store-page-kB4rot2u.js} +1 -1
  40. package/src/console/ui-next/dist/assets/{trace-detail-sheet-B2c9QRBw.js → trace-detail-sheet-B2VrPfhu.js} +1 -1
  41. package/src/console/ui-next/dist/assets/{tree-expand-toggle-BP4tRC98.js → tree-expand-toggle-CdecFS0p.js} +1 -1
  42. package/src/console/ui-next/dist/assets/{units-page-Oi6--n09.js → units-page-B-Y68cys.js} +1 -1
  43. package/src/console/ui-next/dist/assets/{vault-page-Dt-cPkiU.js → vault-page-BcDSaubT.js} +1 -1
  44. package/src/console/ui-next/dist/index.html +2 -2
  45. package/src/kernel/app.ts +23 -1
  46. package/src/kernel/boot.ts +9 -5
  47. package/src/kernel/effects-stamping.test.ts +44 -0
  48. package/src/kernel/errors-flow-name.ts +20 -4
  49. package/src/kernel/errors.registry.test.ts +4 -2
  50. package/src/kernel/errors.ts +1 -1
  51. package/src/kernel/flow-name.test.ts +105 -20
  52. package/src/kernel/flow.ts +3 -3
  53. package/src/kernel/on.ts +0 -5
  54. package/src/kernel/stamp-http.test.ts +2 -1
  55. package/src/kernel/stamp-http.ts +5 -7
  56. package/src/kernel/unit.ts +1 -2
  57. 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
 
@@ -34,9 +34,11 @@ cd notes
34
34
  ### Run the dev loop
35
35
 
36
36
  ```bash
37
- oke dev
37
+ bun run dev
38
38
  ```
39
39
 
40
+ `bun run dev` is the portable form (`bunx oke dev` is the same). Bare `oke` needs `node_modules/.bin` on PATH — PowerShell does not add it.
41
+
40
42
  Compose comes up (pull / create / start progress streams into the boot status
41
43
  lines), the backend listens on **6530**, Console on **6533**, app MCP on **6535**,
42
44
  docs MCP on **6536**. Open `http://localhost:6533`. Quit with **Ctrl+C**.
@@ -177,6 +179,17 @@ oke db search-backfill notes --batch 500
177
179
 
178
180
  <Accordions>
179
181
 
182
+ <Accordion title="oke is not recognized (Windows PowerShell)">
183
+ PowerShell has no local `oke` on PATH. Use `bun run dev` or `bunx oke dev`. New Cursor/VS Code
184
+ terminals inherit `node_modules/.bin` from `.vscode/settings.json`. Global: `bun install -g
185
+ okengine`.
186
+ </Accordion>
187
+
188
+ <Accordion title="OKE1020 on oke dev (Compose)">
189
+ The app child had no Manifest to stamp effects (`main.health`). `oke dev` now hands the parent
190
+ extract to the child. A failed extract appends `Manifest extract failed — …` (`oxc-parser`).
191
+ </Accordion>
192
+
180
193
  <Accordion title="oke start: no entry found">
181
194
  Set `package.json` `okengine.entry` or `main`, pass `--entry`, or keep a conventional
182
195
  `src/app.ts`. Production imports that module; the app must call `createBunRuntime().serve` itself.
@@ -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
 
@@ -18,7 +18,7 @@ docker info
18
18
  ```bash
19
19
  bunx create-oke@latest my-app
20
20
  cd my-app
21
- oke dev
21
+ bun run dev
22
22
  ```
23
23
 
24
24
  Open the Console at the address printed in the terminal and claim it with the code shown there. The Flows listed weren't configured anywhere — they were derived from the code you just scaffolded.
@@ -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
 
@@ -11,6 +11,9 @@
11
11
  * - `PORT` — listen port (`0` = ephemeral)
12
12
  * - `OKE_HOSTNAME` — listen hostname (default `127.0.0.1`)
13
13
  * - `OKE_READY_PATH` — when set, write bound port here once listening
14
+ * - `OKE_ROOT_DIR` — project root for Manifest extract (defaults to cwd)
15
+ * - `OKE_MANIFEST_PATH` — optional JSON Manifest from the parent `oke dev`
16
+ * extract (avoids a second extract in the child; Windows-safe fallback)
14
17
  *
15
18
  * Soft reload must not clear the TTY — the parent `oke dev` board owns
16
19
  * Docker status and the first-admin claim code.
@@ -19,6 +22,7 @@
19
22
  import { resolve } from "node:path";
20
23
  import { pathToFileURL } from "node:url";
21
24
  import { installGracefulShutdown, type GracefulShutdownApp } from "../kernel/graceful-shutdown.ts";
25
+ import type { Manifest } from "../manifest/types.ts";
22
26
  import { createBunRuntime } from "../runtime/bun.ts";
23
27
  import { APP_PORT, type FetchApp } from "../runtime/types.ts";
24
28
  import { formatAppReadyLine } from "../term.ts";
@@ -28,7 +32,7 @@ export const DEV_APP_SERVE_ID = "oke-dev-app";
28
32
 
29
33
  /** App entry shape — FetchApp plus boot before serve. */
30
34
  type BootableApp = FetchApp & {
31
- boot(): Promise<unknown>;
35
+ boot(overrides?: { readonly rootDir?: string; readonly manifest?: Manifest }): Promise<unknown>;
32
36
  stop(): Promise<void>;
33
37
  readonly bootResult?: GracefulShutdownApp["bootResult"];
34
38
  };
@@ -49,6 +53,20 @@ const readyPath = Bun.env["OKE_READY_PATH"];
49
53
  // already the project root here (Bun.spawn's `cwd` option in dev.ts).
50
54
  process.env["OKE_ROOT_DIR"] ??= process.cwd();
51
55
 
56
+ const manifestPath = Bun.env["OKE_MANIFEST_PATH"];
57
+ let manifest: Manifest | undefined;
58
+ if (manifestPath !== undefined && manifestPath.length > 0) {
59
+ try {
60
+ manifest = (await Bun.file(manifestPath).json()) as Manifest;
61
+ } catch (err) {
62
+ console.error(
63
+ `oke dev-app-runner: OKE_MANIFEST_PATH unreadable — ${
64
+ err instanceof Error ? err.message : String(err)
65
+ }`,
66
+ );
67
+ }
68
+ }
69
+
52
70
  const absoluteEntry = resolve(entry);
53
71
  const mod = (await import(pathToFileURL(absoluteEntry).href)) as {
54
72
  app?: BootableApp;
@@ -63,7 +81,10 @@ if (
63
81
  process.exit(1);
64
82
  }
65
83
 
66
- await mod.app.boot();
84
+ await mod.app.boot({
85
+ rootDir: process.env["OKE_ROOT_DIR"],
86
+ ...(manifest !== undefined ? { manifest } : {}),
87
+ });
67
88
  const handle = createBunRuntime().serve(mod.app, {
68
89
  port,
69
90
  hostname,
@@ -1070,8 +1070,55 @@ export const posts = store.schema.table("posts", {
1070
1070
  });
1071
1071
 
1072
1072
  describe("oke dev Console vault config", () => {
1073
+ let session: DevSession | undefined;
1074
+
1075
+ afterEach(() => {
1076
+ session?.stop();
1077
+ session = undefined;
1078
+ });
1079
+
1073
1080
  test("hands loaded oke.config to Console so drivers.vault is not the env default", async () => {
1074
1081
  const src = await Bun.file(new URL("./dev.ts", import.meta.url)).text();
1075
1082
  expect(src).toMatch(/okeConfig:\s*loadedConfig/);
1076
1083
  });
1084
+
1085
+ test("writes OKE_MANIFEST_PATH for the app child when a Manifest is available", async () => {
1086
+ const runner = await Bun.file(new URL("./dev-app-runner.ts", import.meta.url)).text();
1087
+ expect(runner).toContain("OKE_MANIFEST_PATH");
1088
+ expect(runner).toMatch(/mod\.app\.boot\(\{/);
1089
+
1090
+ const dir = await mkdtemp(join(tmpdir(), "oke-dev-manifest-handoff-"));
1091
+ await Bun.write(join(dir, "src/app.ts"), "export {}\n");
1092
+ await Bun.write(join(dir, "oke.manifest.json"), JSON.stringify(LIVE_MANIFEST));
1093
+ let seen: Record<string, string> | undefined;
1094
+ const result = await runDev({
1095
+ stdinIsTTY: false,
1096
+ cwd: dir,
1097
+ silentClaim: true,
1098
+ keepAlive: false,
1099
+ appPort: 0,
1100
+ consolePort: 0,
1101
+ mcpPort: 0,
1102
+ docsMcpPort: 0,
1103
+ ...stubCompose(),
1104
+ startApp: async (_entry, env) => {
1105
+ seen = env;
1106
+ return { stop() {} };
1107
+ },
1108
+ regenClient: async () => {},
1109
+ write: () => {},
1110
+ serveConsole: async () => ({ stop() {} }),
1111
+ serveMcp: async () => ({ stop() {} }),
1112
+ serveDocsMcp: async () => ({
1113
+ stop() {},
1114
+ port: 1,
1115
+ url: new URL("http://127.0.0.1:1"),
1116
+ }),
1117
+ });
1118
+ session = result.session;
1119
+ expect(result.code).toBe(0);
1120
+ expect(seen?.["OKE_MANIFEST_PATH"]).toBeTruthy();
1121
+ const dumped = JSON.parse(await Bun.file(seen!["OKE_MANIFEST_PATH"]!).text()) as Manifest;
1122
+ expect(dumped.app).toBe("dev-live");
1123
+ });
1077
1124
  });
package/src/cli/dev.ts CHANGED
@@ -1067,6 +1067,13 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
1067
1067
  const seedManifest =
1068
1068
  options.manifest !== undefined ? options.manifest : await tryLoadProjectManifest(cwd);
1069
1069
 
1070
+ let seedManifestPath: string | undefined;
1071
+ if (seedManifest) {
1072
+ seedManifestPath = join(tmpdir(), `oke-dev-manifest-${crypto.randomUUID()}.json`);
1073
+ await Bun.write(seedManifestPath, `${JSON.stringify(seedManifest)}\n`);
1074
+ env.OKE_MANIFEST_PATH = seedManifestPath;
1075
+ }
1076
+
1070
1077
  async function refreshManifestInto(state: ConsoleState | null): Promise<void> {
1071
1078
  if (!state) return;
1072
1079
  try {
@@ -1401,6 +1408,10 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
1401
1408
  consoleVite = null;
1402
1409
  void vite?.stop();
1403
1410
  void clearDevSessionLock(cwd);
1411
+ if (seedManifestPath) {
1412
+ void unlink(seedManifestPath).catch(() => {});
1413
+ seedManifestPath = undefined;
1414
+ }
1404
1415
  if (dockerStarted) {
1405
1416
  const started = dockerStarted;
1406
1417
  dockerStarted = null;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Manifest extract must not walk `node_modules` — Windows glob paths use `\`
3
+ * and a naive `includes("node_modules/")` miss lets framework fixtures throw
4
+ * during extract, which Docker-first `oke dev` surfaces as **OKE1020**.
5
+ */
6
+
7
+ import { describe, expect, test } from "bun:test";
8
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { extractManifest } from "./extract.ts";
12
+
13
+ describe("extractManifest — skip dependency trees", () => {
14
+ test("does not parse node_modules even when a poison file would fail extract", async () => {
15
+ const dir = await mkdtemp(join(tmpdir(), "oke-extract-skip-"));
16
+ try {
17
+ await mkdir(join(dir, "src/flows/main"), { recursive: true });
18
+ await writeFile(
19
+ join(dir, "src/flows/main/health.ts"),
20
+ `
21
+ import { on, flow, http } from "okengine/http";
22
+ export const health = on(
23
+ http.get("/health").public(),
24
+ flow({ do: () => ({ ok: true as const }) }),
25
+ );
26
+ `,
27
+ );
28
+ await mkdir(join(dir, "node_modules/okengine"), { recursive: true });
29
+ // Nameless Signal consumer — **OKE1072** if this file is scanned.
30
+ await writeFile(
31
+ join(dir, "node_modules/okengine/poison.ts"),
32
+ `
33
+ import { on, flow, signal } from "okengine";
34
+ export const inbound = on(signal.once("x"), flow({ do: () => ({}) }));
35
+ `,
36
+ );
37
+ const manifest = await extractManifest({ rootDir: dir });
38
+ expect(manifest.flows?.["main.health"]).toBeDefined();
39
+ } finally {
40
+ await rm(dir, { recursive: true, force: true });
41
+ }
42
+ });
43
+ });