okengine 0.9.1 → 0.10.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 (175) hide show
  1. package/AGENTS.md +1 -1
  2. package/manifest.v1.schema.json +9 -1
  3. package/package.json +3 -3
  4. package/site/content/docs/console/runs.mdx +10 -0
  5. package/site/content/docs/console/traces.mdx +4 -0
  6. package/site/content/docs/elements/ai.mdx +54 -32
  7. package/site/content/docs/elements/channel.mdx +97 -9
  8. package/site/content/docs/elements/clock.mdx +1 -0
  9. package/site/content/docs/elements/flow.mdx +42 -8
  10. package/site/content/docs/get-started/basic-usage.mdx +5 -1
  11. package/site/content/docs/get-started/installation.mdx +9 -5
  12. package/site/content/docs/meta.json +2 -0
  13. package/site/content/docs/providers/azure-redis.mdx +85 -0
  14. package/site/content/docs/providers/cockroachdb.mdx +86 -0
  15. package/site/content/docs/providers/digitalocean-caching.mdx +88 -0
  16. package/site/content/docs/providers/dragonfly-cloud.mdx +82 -0
  17. package/site/content/docs/providers/elasticache.mdx +92 -0
  18. package/site/content/docs/providers/index.mdx +101 -0
  19. package/site/content/docs/providers/memorystore.mdx +89 -0
  20. package/site/content/docs/providers/meta.json +20 -0
  21. package/site/content/docs/providers/neon.mdx +115 -0
  22. package/site/content/docs/providers/redis-cloud.mdx +93 -0
  23. package/site/content/docs/providers/supabase.mdx +117 -0
  24. package/site/content/docs/providers/upstash.mdx +91 -0
  25. package/site/content/docs/providers/yugabytedb.mdx +95 -0
  26. package/site/content/docs/recipes/caddy.mdx +128 -0
  27. package/site/content/docs/recipes/cockroachdb.mdx +127 -0
  28. package/site/content/docs/recipes/dragonfly.mdx +125 -0
  29. package/site/content/docs/recipes/index.mdx +146 -0
  30. package/site/content/docs/recipes/llama-cpp.mdx +153 -0
  31. package/site/content/docs/recipes/mailpit.mdx +136 -0
  32. package/site/content/docs/recipes/meilisearch.mdx +139 -0
  33. package/site/content/docs/recipes/meta.json +31 -0
  34. package/site/content/docs/recipes/ollama.mdx +142 -0
  35. package/site/content/docs/recipes/openbao.mdx +141 -0
  36. package/site/content/docs/recipes/pgdog.mdx +137 -0
  37. package/site/content/docs/recipes/postgres.mdx +136 -0
  38. package/site/content/docs/recipes/redis.mdx +130 -0
  39. package/site/content/docs/recipes/rustfs.mdx +148 -0
  40. package/site/content/docs/recipes/sglang.mdx +105 -0
  41. package/site/content/docs/recipes/supabase-docker.mdx +145 -0
  42. package/site/content/docs/recipes/timescale.mdx +129 -0
  43. package/site/content/docs/recipes/traefik.mdx +136 -0
  44. package/site/content/docs/recipes/valkey.mdx +121 -0
  45. package/site/content/docs/recipes/vllm.mdx +106 -0
  46. package/site/content/docs/recipes/yugabytedb.mdx +135 -0
  47. package/site/content/docs/reference/cli.md +42 -6
  48. package/site/content/docs/reference/configuration.mdx +5 -3
  49. package/site/content/docs/reference/environment-variables.mdx +12 -12
  50. package/site/content/docs/reference/fx.mdx +38 -4
  51. package/src/cli/ai-setup/ai-setup.test.ts +32 -12
  52. package/src/cli/ai-setup/apply.ts +65 -23
  53. package/src/cli/ai-setup/catalog.ts +1316 -35
  54. package/src/cli/ai-setup/detect-ollama.ts +47 -0
  55. package/src/cli/ai-setup/index.ts +44 -7
  56. package/src/cli/ai-setup/prompts.ts +430 -486
  57. package/src/cli/ai-setup/recommend.test.ts +150 -50
  58. package/src/cli/ai-setup/recommend.ts +118 -101
  59. package/src/cli/ask-seed.test.ts +68 -0
  60. package/src/cli/ask-seed.ts +79 -0
  61. package/src/cli/db-seed-redact.test.ts +25 -0
  62. package/src/cli/db-seed.ts +36 -7
  63. package/src/cli/db.test.ts +1 -1
  64. package/src/cli/db.ts +2 -2
  65. package/src/cli/dev-app-runner.ts +6 -0
  66. package/src/cli/dev-controls.test.ts +107 -0
  67. package/src/cli/dev-controls.ts +354 -0
  68. package/src/cli/dev-schema-sync.ts +12 -4
  69. package/src/cli/dev.test.ts +50 -30
  70. package/src/cli/dev.ts +633 -66
  71. package/src/cli/docker-cli.test.ts +15 -0
  72. package/src/cli/drizzle-env.test.ts +40 -6
  73. package/src/cli/drizzle-env.ts +22 -1
  74. package/src/cli/ensure-drizzle-config.ts +6 -4
  75. package/src/cli/hero-meta.test.ts +13 -1
  76. package/src/cli/hero-meta.ts +28 -6
  77. package/src/cli/index.ts +5 -0
  78. package/src/cli/registry.ts +24 -0
  79. package/src/cli/replay.test.ts +105 -0
  80. package/src/cli/replay.ts +206 -0
  81. package/src/cli/start.ts +12 -1
  82. package/src/compiler/effects-infer.ts +37 -8
  83. package/src/compiler/extract.test.ts +125 -0
  84. package/src/compiler/extract.ts +31 -6
  85. package/src/config/index.ts +3 -3
  86. package/src/console/server/claim.ts +3 -1
  87. package/src/console/ui/dist/assets/{index-ClX0ZCe5.js → index-D-9KFce5.js} +2 -2
  88. package/src/console/ui/dist/assets/panel-overview-CW0CQEc2.js +1 -0
  89. package/src/console/ui/dist/assets/panel-runs-BxuyDI3x.js +1 -0
  90. package/src/console/ui/dist/assets/{panel-signals-Dsxdu_AR.js → panel-signals-BXofbj7v.js} +1 -1
  91. package/src/console/ui/dist/assets/{panel-store-hy7O8HOs.js → panel-store-B8mH-arU.js} +1 -1
  92. package/src/console/ui/dist/assets/{panel-traces-CBiAe5go.js → panel-traces-DSA-ZWnf.js} +1 -1
  93. package/src/console/ui/dist/index.html +1 -1
  94. package/src/console/ui/overview/slo.ts +31 -3
  95. package/src/console/ui/overview/types.ts +6 -0
  96. package/src/console/ui/runs/errors.test.ts +53 -0
  97. package/src/console/ui/runs/errors.ts +88 -0
  98. package/src/console/ui/runs/index.ts +8 -0
  99. package/src/console/ui/runs/search.ts +16 -0
  100. package/src/console/ui/shell/panels/runs/RunsPanel.tsx +72 -1
  101. package/src/docker/ai-model-status.test.ts +101 -0
  102. package/src/docker/ai-model-status.ts +276 -0
  103. package/src/docker/compose-health.test.ts +115 -0
  104. package/src/docker/compose-health.ts +234 -0
  105. package/src/docker/compose.ts +24 -8
  106. package/src/docker/derive.ts +35 -9
  107. package/src/docker/docker.test.ts +304 -12
  108. package/src/docker/helpers.ts +56 -0
  109. package/src/docker/index.ts +52 -0
  110. package/src/docker/ollama-pull.ts +232 -0
  111. package/src/docker/recipes/cockroach.ts +24 -0
  112. package/src/docker/recipes/index.ts +32 -0
  113. package/src/docker/recipes/llama-cpp.ts +273 -0
  114. package/src/docker/recipes/ollama.ts +19 -23
  115. package/src/docker/recipes/pgdog.ts +4 -1
  116. package/src/docker/recipes/postgres.ts +5 -18
  117. package/src/docker/recipes/sglang.ts +55 -0
  118. package/src/docker/recipes/supabase.ts +21 -0
  119. package/src/docker/recipes/timescale.ts +18 -0
  120. package/src/docker/recipes/vllm.ts +44 -0
  121. package/src/docker/recipes/yugabyte.ts +23 -0
  122. package/src/docker/stack-id.test.ts +22 -0
  123. package/src/docker/stack-id.ts +38 -4
  124. package/src/docker/types.ts +15 -0
  125. package/src/drivers/ai-ollama.integration.test.ts +7 -4
  126. package/src/drivers/signal-engine.ts +10 -0
  127. package/src/drivers/signal-postgres.ts +24 -2
  128. package/src/drivers/signal-types.ts +10 -0
  129. package/src/elements/channel/fallback.test.ts +91 -0
  130. package/src/elements/channel/hard-bounce.test.ts +82 -0
  131. package/src/elements/channel/injection.test.ts +153 -0
  132. package/src/elements/channel/locale-ar.test.ts +102 -0
  133. package/src/elements/channel/otp-delivery.ts +5 -2
  134. package/src/elements/channel/receipts-ledger.test.ts +100 -0
  135. package/src/elements/channel/runtime.ts +24 -1
  136. package/src/elements/channel/test-helpers.ts +60 -0
  137. package/src/elements/channel/whatsapp-compliance.test.ts +124 -0
  138. package/src/elements/store/cache.test.ts +28 -0
  139. package/src/elements/store/cache.ts +1 -1
  140. package/src/elements/store/emit-drizzle.ts +43 -9
  141. package/src/elements/store/runtime.ts +1 -1
  142. package/src/elements/store/schema-decl.test.ts +2 -0
  143. package/src/elements/store/schema-decl.ts +15 -0
  144. package/src/elements/store/seed.ts +1 -1
  145. package/src/index.ts +1 -0
  146. package/src/kernel/app.ts +131 -10
  147. package/src/kernel/boot-bind/signal.ts +6 -2
  148. package/src/kernel/boot-bind/store.ts +2 -1
  149. package/src/kernel/boot.test.ts +6 -5
  150. package/src/kernel/boot.ts +113 -10
  151. package/src/kernel/compensate.test.ts +131 -0
  152. package/src/kernel/correlation.test.ts +108 -0
  153. package/src/kernel/effect-timing.test.ts +82 -0
  154. package/src/kernel/effects-stamping.test.ts +207 -0
  155. package/src/kernel/errors.ts +11 -0
  156. package/src/kernel/flow.ts +38 -0
  157. package/src/kernel/fx-runs.test.ts +49 -0
  158. package/src/kernel/fx.test.ts +14 -0
  159. package/src/kernel/fx.ts +153 -14
  160. package/src/manifest/sql-resource.ts +41 -0
  161. package/src/manifest/types.ts +8 -2
  162. package/src/runs/collect.ts +3 -0
  163. package/src/runs/export-otlp.ts +64 -0
  164. package/src/runs/index.ts +16 -0
  165. package/src/runs/parquet.ts +9 -0
  166. package/src/runs/types.ts +5 -0
  167. package/src/runs/window.test.ts +68 -0
  168. package/src/runs/window.ts +148 -0
  169. package/src/runtime/boot-warn.test.ts +45 -0
  170. package/src/runtime/boot-warn.ts +17 -0
  171. package/src/runtime/dev-request-log.ts +5 -5
  172. package/src/term.test.ts +95 -10
  173. package/src/term.ts +369 -14
  174. package/src/console/ui/dist/assets/panel-overview-DONDzBd2.js +0 -1
  175. package/src/console/ui/dist/assets/panel-runs-D5zf-D9c.js +0 -1
@@ -79,7 +79,7 @@ describe("boot — lazy element needs", () => {
79
79
  expect(needs.signal).toBe(false);
80
80
  });
81
81
 
82
- test("oke() Store-only graph stays under the prior 53 kB baseline", async () => {
82
+ test("oke() Store-only graph stays under the prior 54 kB baseline", async () => {
83
83
  const dir = await mkdtemp(join(tmpdir(), "oke-store-only-"));
84
84
  const entry = join(dir, "entry.ts");
85
85
  const appPath = join(import.meta.dir, "app.ts");
@@ -117,10 +117,11 @@ describe("boot — lazy element needs", () => {
117
117
  total += Bun.gzipSync(new Uint8Array(raw)).byteLength;
118
118
  }
119
119
  // Rebased after fx.sendOtp/verifyOtp on the shared fx surface (~50.1 kB
120
- // gzip), durable-journal lease surface (~51.2 kB), then fx.deliverOtp +
121
- // unified otp() plugin wiring on the shared surface (~50.9 kB → 53 kB
122
- // cap). Clock/channel/journal drivers stay lazy-bound; far below eager bind.
123
- expect(total).toBeLessThan(53_000);
120
+ // gzip), durable-journal lease surface (~51.2 kB), fx.deliverOtp +
121
+ // unified otp() (~50.9 kB → 53 kB), then fx.runs / SLO window on the
122
+ // shared surface (~53.3 kB → 54 kB cap). Clock/channel/journal drivers
123
+ // stay lazy-bound; far below eager bind.
124
+ expect(total).toBeLessThan(54_000);
124
125
  } finally {
125
126
  await rm(dir, { recursive: true, force: true });
126
127
  }
@@ -17,7 +17,10 @@
17
17
  * Element runtimes are loaded via `new URL("./boot-bind/"+name+".ts", …)` so
18
18
  * unused binders stay out of the `oke()` bundle (semantic tree-shaking).
19
19
  * Vault still loads whenever secrets are declared and lists every gap in one
20
- * failure; capability minting uses flow effect refs only — no element modules.
20
+ * failure; capability minting uses flow effect refs only — no element modules,
21
+ * except a lazy `extractManifest` import when a flow has no hand-declared
22
+ * `effects` and neither {@link BootOptions.manifest} nor
23
+ * {@link BootOptions.rootDir} / `OKE_ROOT_DIR` are set to derive one.
21
24
  *
22
25
  * The scheduler reads the effective state from the Store after reconciliation,
23
26
  * never the code directly (console §5).
@@ -38,8 +41,11 @@ import type {
38
41
  import type { JournalRuntime } from "./boot-bind/journal.ts";
39
42
  import type { CreateRunsRuntimeOptions, RunsRuntime } from "../runs/index.ts";
40
43
  import { createCapabilityToken, type CapabilityToken } from "./capability.ts";
44
+ import { throwOke } from "./errors.ts";
41
45
  import type { AnyFlowDef } from "./flow.ts";
42
46
  import type { Binding } from "./on.ts";
47
+ import type { Manifest } from "../manifest/types.ts";
48
+ import { emitBootWarn } from "../runtime/boot-warn.ts";
43
49
 
44
50
  /** Pre-built or partially-built element runtimes. */
45
51
  export interface ElementRuntimes {
@@ -104,6 +110,21 @@ export interface BootOptions {
104
110
  readonly bindings?: readonly Binding[];
105
111
  /** Flows known to the app (for capability minting). */
106
112
  readonly flows?: readonly AnyFlowDef[];
113
+ /**
114
+ * Compiled Manifest to derive capability tokens from for flows with no
115
+ * hand-declared `effects` (highest priority — build tooling / bundler
116
+ * import, never a filesystem read the kernel performs itself).
117
+ */
118
+ readonly manifest?: Manifest;
119
+ /**
120
+ * Project root for a lazy, best-effort `extractManifest` when a flow has
121
+ * no hand-declared `effects` and no {@link manifest} was given. Explicit
122
+ * opt-in only — never defaults to `process.cwd()` — so the existing test
123
+ * suite (hundreds of boots against synthetic flows, not a real source
124
+ * tree) never pays extraction cost. Falls back to `OKE_ROOT_DIR` when
125
+ * unset (the CLI sets this for `oke dev` / `oke start`).
126
+ */
127
+ readonly rootDir?: string;
107
128
  /**
108
129
  * Dispatch a cron / every interval when the scheduler fires.
109
130
  *
@@ -115,8 +136,13 @@ export interface BootOptions {
115
136
  *
116
137
  * @param signal - Signal name
117
138
  * @param payload - Payload
139
+ * @param meta - Optional envelope (producer run id for trace chains)
118
140
  */
119
- readonly onSignal?: (signal: string, payload: unknown) => void | Promise<void>;
141
+ readonly onSignal?: (
142
+ signal: string,
143
+ payload: unknown,
144
+ meta?: { readonly parentRunId?: string; readonly messageId?: string },
145
+ ) => void | Promise<void>;
120
146
  /**
121
147
  * Resume due durable runs — called on every scheduler tick when any flow
122
148
  * declares `durable: true` (claimDueSleep on the shared journal store).
@@ -257,8 +283,8 @@ async function loadBind<T>(name: string): Promise<T> {
257
283
  export async function bootApplication(input: BootOptions = {}): Promise<BootResult> {
258
284
  const docker =
259
285
  input.docker === true || (input.docker !== false && process.env.OKE_DOCKER === "1");
260
- // `-d` always selects the `docker` driver profile — not a mix of test/local +
261
- // prod store overrides (templates often pin `env: "test"` for harnesses).
286
+ // `-d` always selects the `docker` driver profile — not a mix of local/test +
287
+ // prod store overrides when `$options.env` is unset.
262
288
  const env: ConfigEnv = docker ? "docker" : (input.env ?? "local");
263
289
  let config = input.config;
264
290
  if (config === undefined) {
@@ -452,8 +478,11 @@ export async function bootApplication(input: BootOptions = {}): Promise<BootResu
452
478
  }
453
479
  }
454
480
 
455
- // 8. Caps — effect refs only; no element modules.
456
- const capabilities = mintCapabilities(options.flows ?? []);
481
+ // 8. Caps — effect refs only; no element modules (unless a lazy extract is needed).
482
+ const capabilities = await mintCapabilities(options.flows ?? [], env, {
483
+ manifest: options.manifest,
484
+ rootDir: options.rootDir,
485
+ });
457
486
 
458
487
  return {
459
488
  vault,
@@ -486,16 +515,90 @@ export async function bootApplication(input: BootOptions = {}): Promise<BootResu
486
515
  };
487
516
  }
488
517
 
518
+ /** Per-process guard so the "no effects" boot warning fires once, not per-flow. */
519
+ let noEffectsWarned = false;
520
+
521
+ /** Reset the once-per-process "no effects" warn latch (tests only). */
522
+ export function resetNoEffectsWarnForTests(): void {
523
+ noEffectsWarned = false;
524
+ }
525
+
526
+ /**
527
+ * Best-effort AoT extraction from `rootDir` — mirrors the CLI's own lazy,
528
+ * defensive `extractManifest` use (`oke dev`'s `tryLoadProjectManifest`).
529
+ * Never throws: a broken or mid-edit source tree just means "no Manifest
530
+ * available," handled by the caller like any other missing Manifest.
531
+ *
532
+ * `new URL` (not a literal specifier) so `oxc-parser` / the whole compiler
533
+ * never enters the bundler graph for apps that never hit this path — same
534
+ * trick as {@link loadBind} for the element binders.
535
+ *
536
+ * @param rootDir - Project root to extract from
537
+ */
538
+ async function tryAutoExtractManifest(rootDir: string): Promise<Manifest | undefined> {
539
+ try {
540
+ const url = new URL("../compiler/extract.ts", import.meta.url);
541
+ const { extractManifest } = (await import(url.href)) as typeof import("../compiler/extract.ts");
542
+ return await extractManifest({ rootDir });
543
+ } catch {
544
+ return undefined;
545
+ }
546
+ }
547
+
489
548
  /**
490
- * Mint capability tokens from each flow's declared effects.
491
- * Tokens come from the Manifest / flow contract never hand-passed sets.
549
+ * Mint capability tokens from each flow's declared effects, falling back to
550
+ * a Manifest-derived stamp when a flow declares no `effects` of its own —
551
+ * an explicit {@link BootOptions.manifest}, or a lazy AoT extract from
552
+ * {@link BootOptions.rootDir} / `OKE_ROOT_DIR`.
553
+ *
554
+ * When neither is available: `local` / `test` stay open (today's dev-loop
555
+ * behavior, unbroken) with a once-per-process `oke boot:` warning; `docker`
556
+ * / `prod` fail loud (`OKE1008`) — docker mirrors prod's posture, never a
557
+ * silent open door in a deploy-shaped environment.
492
558
  *
493
559
  * @param flows - Adopted flows
560
+ * @param env - Resolved {@link ConfigEnv}
561
+ * @param options - Manifest / rootDir sources for the fallback stamp
494
562
  */
495
- export function mintCapabilities(flows: readonly AnyFlowDef[]): Map<string, CapabilityToken> {
563
+ export async function mintCapabilities(
564
+ flows: readonly AnyFlowDef[],
565
+ env: ConfigEnv = "local",
566
+ options: { readonly manifest?: Manifest; readonly rootDir?: string } = {},
567
+ ): Promise<Map<string, CapabilityToken>> {
496
568
  const map = new Map<string, CapabilityToken>();
569
+
570
+ let manifest = options.manifest;
571
+ const needsManifest = manifest === undefined && flows.some((f) => f.effects === undefined);
572
+ if (needsManifest) {
573
+ const rootDir = options.rootDir ?? process.env["OKE_ROOT_DIR"];
574
+ if (rootDir) manifest = await tryAutoExtractManifest(rootDir);
575
+ }
576
+
497
577
  for (const f of flows) {
498
- map.set(f.name, createCapabilityToken(f.name, f.effects));
578
+ if (f.effects !== undefined) {
579
+ map.set(f.name, createCapabilityToken(f.name, f.effects));
580
+ continue;
581
+ }
582
+
583
+ const stamped = manifest?.flows?.[f.name]?.effects;
584
+ if (stamped !== undefined) {
585
+ map.set(f.name, createCapabilityToken(f.name, stamped));
586
+ continue;
587
+ }
588
+
589
+ if (env === "docker" || env === "prod") {
590
+ throwOke("NO_EFFECTS_DECLARED", { flow: f.name });
591
+ }
592
+ if (!noEffectsWarned) {
593
+ noEffectsWarned = true;
594
+ emitBootWarn(
595
+ `oke boot: flow "${f.name}" (and possibly others) has no declared effects and no ` +
596
+ "Manifest-derived effects — running with an OPEN capability token (every access " +
597
+ "allowed, ledgered but not gated). Run `oke build`, or boot with `manifest` / " +
598
+ "`rootDir`, before deploying — docker/prod refuse to boot this way.",
599
+ );
600
+ }
601
+ map.set(f.name, createCapabilityToken(f.name, undefined));
499
602
  }
500
603
  return map;
501
604
  }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Durable compensation — terminal failure only; undo steps are distinct
3
+ * journaled names that survive orphan resume without re-running forwards.
4
+ */
5
+
6
+ import { describe, expect, test } from "bun:test";
7
+ import { createMemoryJournalStore } from "./journal.ts";
8
+ import type { JournalRuntime } from "./boot-bind/journal.ts";
9
+ import { flow } from "./flow.ts";
10
+ import { oke } from "./app.ts";
11
+ import { createRunsRuntime } from "../runs/runtime.ts";
12
+
13
+ function memoryJournal(store = createMemoryJournalStore()): JournalRuntime {
14
+ return {
15
+ store,
16
+ instanceId: "test-inst",
17
+ leaseMs: 30_000,
18
+ driverId: "memory",
19
+ };
20
+ }
21
+
22
+ describe("durable compensate", () => {
23
+ test("throw commits journal failed and runs compensate undo steps", async () => {
24
+ const store = createMemoryJournalStore();
25
+ const undos: string[] = [];
26
+
27
+ const pay = flow({
28
+ name: "pay.charge",
29
+ durable: true,
30
+ do: async (_input: { amount: number }, fx) => {
31
+ await fx.step("reserve", async () => "reserved");
32
+ await fx.step("charge", async () => {
33
+ throw new Error("card_declined");
34
+ });
35
+ return { ok: true as const };
36
+ },
37
+ compensate: async (_ctx, fx) => {
38
+ await fx.step("undo:charge", async () => {
39
+ undos.push("undo:charge");
40
+ });
41
+ await fx.step("undo:reserve", async () => {
42
+ undos.push("undo:reserve");
43
+ });
44
+ },
45
+ });
46
+
47
+ const app = oke({
48
+ name: "compensate-throw",
49
+ env: "test",
50
+ startScheduler: false,
51
+ gate: { unguardedHttp: "allow" },
52
+ elements: { journal: memoryJournal(store) },
53
+ }).adopt(pay);
54
+
55
+ await app.boot();
56
+ const result = await app.execute(pay, { amount: 10 }, { kind: "internal" });
57
+ expect(result.ctx.error).toBeDefined();
58
+
59
+ const runs = await store.list();
60
+ expect(runs).toHaveLength(1);
61
+ expect(runs[0]!.status).toBe("failed");
62
+ expect(runs[0]!.error).toBe("card_declined");
63
+ expect(undos).toEqual(["undo:charge", "undo:reserve"]);
64
+ const stepNames = runs[0]!.entries
65
+ .filter((e) => e.kind === "step")
66
+ .map((e) => (e as { name: string }).name);
67
+ expect(stepNames).toContain("reserve");
68
+ expect(stepNames).toContain("undo:reserve");
69
+ expect(stepNames).toContain("undo:charge");
70
+ });
71
+
72
+ test("compensate does not run on successful path", async () => {
73
+ let compensated = 0;
74
+ const store = createMemoryJournalStore();
75
+ const ok = flow({
76
+ name: "pay.ok",
77
+ durable: true,
78
+ do: async (_input, fx) => {
79
+ await fx.step("a", () => "done");
80
+ return { ok: true as const };
81
+ },
82
+ compensate: async () => {
83
+ compensated += 1;
84
+ },
85
+ });
86
+
87
+ const app = oke({
88
+ name: "compensate-ok",
89
+ env: "test",
90
+ startScheduler: false,
91
+ gate: { unguardedHttp: "allow" },
92
+ elements: { journal: memoryJournal(store) },
93
+ }).adopt(ok);
94
+ await app.boot();
95
+ const result = await app.execute(ok, {}, { kind: "internal" });
96
+ expect(result.failure).toBeUndefined();
97
+ expect(compensated).toBe(0);
98
+ expect((await store.list())[0]!.status).toBe("completed");
99
+ });
100
+
101
+ test("WideEvent records thrown errors after classification fix", async () => {
102
+ const runs = createRunsRuntime({ driver: "memory" });
103
+ await runs.open();
104
+ const store = createMemoryJournalStore();
105
+
106
+ const boom = flow({
107
+ name: "pay.boom",
108
+ durable: true,
109
+ do: async () => {
110
+ throw new Error("boom");
111
+ },
112
+ });
113
+
114
+ const app = oke({
115
+ name: "compensate-runs",
116
+ env: "test",
117
+ startScheduler: false,
118
+ gate: { unguardedHttp: "allow" },
119
+ runs,
120
+ elements: { journal: memoryJournal(store) },
121
+ }).adopt(boom);
122
+ await app.boot();
123
+ await app.execute(boom, {}, { kind: "internal" });
124
+ await runs.flush();
125
+
126
+ const events = await runs.all();
127
+ expect(events[0]!.error?.code).toBe("boom");
128
+ expect((await store.list())[0]!.status).toBe("failed");
129
+ await runs.close();
130
+ });
131
+ });
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Trace parentId continuity: HTTP/producer → fx.emit → Signal → consumer Runs.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { signal } from "../elements/signal/declare.ts";
7
+ import { createRunsRuntime } from "../runs/runtime.ts";
8
+ import { flow } from "./flow.ts";
9
+ import { oke } from "./app.ts";
10
+ import { on, resetBindings } from "./on.ts";
11
+ import { http } from "./triggers.ts";
12
+
13
+ describe("correlation — parentId across Flow → Signal → Flow", () => {
14
+ test("consumer WideEvent.parentId equals producer run id", async () => {
15
+ resetBindings();
16
+ const orderPlaced = signal("corr-order-placed", { delivery: "once" });
17
+ const runs = createRunsRuntime({ driver: "memory" });
18
+ await runs.open();
19
+
20
+ const producer = flow({
21
+ name: "orders.create",
22
+ effects: { emits: ["corr-order-placed"] },
23
+ do: async (input: { id: string }, fx) => {
24
+ await fx.emit(orderPlaced, { id: input.id });
25
+ return { ok: true as const };
26
+ },
27
+ });
28
+
29
+ const consumer = flow({
30
+ name: "orders.notify",
31
+ do: async (_input: { id: string }) => ({ notified: true as const }),
32
+ });
33
+
34
+ on(http.post("/orders"), producer);
35
+ on(orderPlaced, consumer);
36
+
37
+ const app = oke({
38
+ name: "corr-signal",
39
+ runs,
40
+ signals: [orderPlaced],
41
+ gate: { unguardedHttp: "allow" },
42
+ startScheduler: false,
43
+ });
44
+
45
+ await app.boot({ env: "test", unguardedHttp: "allow" });
46
+ const res = await app.fetch(
47
+ new Request("http://localhost/orders", {
48
+ method: "POST",
49
+ headers: { "content-type": "application/json" },
50
+ body: JSON.stringify({ id: "ord_1" }),
51
+ }),
52
+ );
53
+ expect(res.ok).toBe(true);
54
+
55
+ const bus = app.bootResult?.signal?.bus;
56
+ expect(bus).toBeDefined();
57
+ await bus!.drain();
58
+ await runs.flush();
59
+
60
+ const events = await runs.all();
61
+ const create = events.find((e) => e.flow === "orders.create");
62
+ const notify = events.find((e) => e.flow === "orders.notify");
63
+ expect(create).toBeDefined();
64
+ expect(notify).toBeDefined();
65
+ expect(notify!.parentId).toBe(create!.id);
66
+
67
+ await runs.close();
68
+ });
69
+
70
+ test("fx.call child records parentId", async () => {
71
+ resetBindings();
72
+ const runs = createRunsRuntime({ driver: "memory" });
73
+ await runs.open();
74
+
75
+ const child = flow({
76
+ name: "inner.work",
77
+ do: () => ({ done: true as const }),
78
+ });
79
+
80
+ const parent = flow({
81
+ name: "outer.work",
82
+ effects: { calls: ["inner.work"] },
83
+ do: async (_input, fx) => {
84
+ await fx.call(child, {});
85
+ return { ok: true as const };
86
+ },
87
+ });
88
+
89
+ const app = oke({
90
+ name: "corr-call",
91
+ autoBoot: false,
92
+ runs,
93
+ gate: { unguardedHttp: "allow" },
94
+ }).adopt(child, parent);
95
+ await app.boot({ env: "test", unguardedHttp: "allow" });
96
+ await app.execute(parent, {}, { kind: "internal" });
97
+ await runs.flush();
98
+
99
+ const events = await runs.all();
100
+ const outer = events.find((e) => e.flow === "outer.work");
101
+ const inner = events.find((e) => e.flow === "inner.work");
102
+ expect(outer).toBeDefined();
103
+ expect(inner).toBeDefined();
104
+ expect(inner!.parentId).toBe(outer!.id);
105
+
106
+ await runs.close();
107
+ });
108
+ });
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Part 4 verification: per-effect timing lives on EffectEntry via recordEffect /
3
+ * gated — not RunTelemetry, not plugin hooks.
4
+ */
5
+
6
+ import { describe, expect, test } from "bun:test";
7
+ import { createEffectLedger, recordEffect } from "./effects.ts";
8
+ import { createFxContext, type FxStubStoreHandle } from "./fx.ts";
9
+ import { HOOK_STAGES } from "./hooks.ts";
10
+ import { createRunTelemetry } from "./run-telemetry.ts";
11
+
12
+ /** Narrow stub handle for tests that exercise the in-memory store. */
13
+ function stub(fx: ReturnType<typeof createFxContext>["fx"], ref: string): FxStubStoreHandle {
14
+ return fx.store(ref) as FxStubStoreHandle;
15
+ }
16
+
17
+ describe("per-effect timing (EffectEntry, not RunTelemetry / hooks)", () => {
18
+ test("recordEffect writes timestamp + duration", async () => {
19
+ const ledger = createEffectLedger();
20
+ let t = 1000;
21
+ await recordEffect(
22
+ ledger,
23
+ "read",
24
+ "sql:notes",
25
+ () => t++,
26
+ async () => {
27
+ t += 40;
28
+ return "ok";
29
+ },
30
+ );
31
+ expect(ledger.entries).toHaveLength(1);
32
+ expect(ledger.entries[0]!.timestamp).toBe(1000);
33
+ expect(ledger.entries[0]!.duration).toBe(41);
34
+ });
35
+
36
+ test("gated fx.store / fx.emit / fx.send populate EffectEntry timing", async () => {
37
+ const telemetry = createRunTelemetry();
38
+ let t = 5000;
39
+ const { fx, ledger } = createFxContext({
40
+ flow: "timing.demo",
41
+ effects: {
42
+ writes: ["kv:cache"],
43
+ emits: ["tick"],
44
+ sends: ["hello"],
45
+ },
46
+ runTelemetry: telemetry,
47
+ now: () => t++,
48
+ channelRuntime: {
49
+ send: async () => ({ ok: true as const }),
50
+ } as never,
51
+ signalRuntime: {
52
+ emit: async () => {},
53
+ } as never,
54
+ });
55
+
56
+ await stub(fx, "kv:cache").set("a", 1);
57
+ await fx.emit("tick", {});
58
+ await fx.send("hello", { to: "u1", data: {} });
59
+
60
+ expect(ledger.entries.map((e) => e.kind)).toEqual(["write", "emit", "send"]);
61
+ for (const e of ledger.entries) {
62
+ expect(e.timestamp).toBeGreaterThan(0);
63
+ expect(e.duration).toBeGreaterThanOrEqual(0);
64
+ }
65
+ // RunTelemetry stays aggregate-only — no effect span list.
66
+ expect("effects" in telemetry).toBe(false);
67
+ expect(telemetry.cacheHits).toBe(0);
68
+ });
69
+
70
+ test("plugin hook stages are pipeline stages, not fx wrap points", () => {
71
+ expect([...HOOK_STAGES]).toEqual([
72
+ "onRequest",
73
+ "onParse",
74
+ "onAuth",
75
+ "beforeHandle",
76
+ "afterHandle",
77
+ "onError",
78
+ "onResponse",
79
+ ]);
80
+ expect(HOOK_STAGES.includes("aroundFx" as never)).toBe(false);
81
+ });
82
+ });