okengine 0.9.1 → 0.10.0

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 (166) 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-controls.test.ts +107 -0
  66. package/src/cli/dev-controls.ts +354 -0
  67. package/src/cli/dev-schema-sync.ts +12 -4
  68. package/src/cli/dev.test.ts +50 -30
  69. package/src/cli/dev.ts +607 -66
  70. package/src/cli/drizzle-env.test.ts +40 -6
  71. package/src/cli/drizzle-env.ts +22 -1
  72. package/src/cli/ensure-drizzle-config.ts +6 -4
  73. package/src/cli/hero-meta.test.ts +13 -1
  74. package/src/cli/hero-meta.ts +28 -6
  75. package/src/cli/index.ts +5 -0
  76. package/src/cli/registry.ts +24 -0
  77. package/src/cli/replay.test.ts +105 -0
  78. package/src/cli/replay.ts +206 -0
  79. package/src/compiler/extract.ts +3 -3
  80. package/src/config/index.ts +3 -3
  81. package/src/console/server/claim.ts +3 -1
  82. package/src/console/ui/dist/assets/{index-ClX0ZCe5.js → index-D-9KFce5.js} +2 -2
  83. package/src/console/ui/dist/assets/panel-overview-CW0CQEc2.js +1 -0
  84. package/src/console/ui/dist/assets/panel-runs-BxuyDI3x.js +1 -0
  85. package/src/console/ui/dist/assets/{panel-signals-Dsxdu_AR.js → panel-signals-BXofbj7v.js} +1 -1
  86. package/src/console/ui/dist/assets/{panel-store-hy7O8HOs.js → panel-store-B8mH-arU.js} +1 -1
  87. package/src/console/ui/dist/assets/{panel-traces-CBiAe5go.js → panel-traces-DSA-ZWnf.js} +1 -1
  88. package/src/console/ui/dist/index.html +1 -1
  89. package/src/console/ui/overview/slo.ts +31 -3
  90. package/src/console/ui/overview/types.ts +6 -0
  91. package/src/console/ui/runs/errors.test.ts +53 -0
  92. package/src/console/ui/runs/errors.ts +88 -0
  93. package/src/console/ui/runs/index.ts +8 -0
  94. package/src/console/ui/runs/search.ts +16 -0
  95. package/src/console/ui/shell/panels/runs/RunsPanel.tsx +72 -1
  96. package/src/docker/ai-model-status.test.ts +101 -0
  97. package/src/docker/ai-model-status.ts +276 -0
  98. package/src/docker/compose-health.test.ts +115 -0
  99. package/src/docker/compose-health.ts +234 -0
  100. package/src/docker/compose.ts +24 -8
  101. package/src/docker/derive.ts +35 -9
  102. package/src/docker/docker.test.ts +304 -12
  103. package/src/docker/helpers.ts +56 -0
  104. package/src/docker/index.ts +52 -0
  105. package/src/docker/ollama-pull.ts +232 -0
  106. package/src/docker/recipes/cockroach.ts +24 -0
  107. package/src/docker/recipes/index.ts +32 -0
  108. package/src/docker/recipes/llama-cpp.ts +273 -0
  109. package/src/docker/recipes/ollama.ts +19 -23
  110. package/src/docker/recipes/pgdog.ts +4 -1
  111. package/src/docker/recipes/postgres.ts +5 -18
  112. package/src/docker/recipes/sglang.ts +55 -0
  113. package/src/docker/recipes/supabase.ts +21 -0
  114. package/src/docker/recipes/timescale.ts +18 -0
  115. package/src/docker/recipes/vllm.ts +44 -0
  116. package/src/docker/recipes/yugabyte.ts +23 -0
  117. package/src/docker/stack-id.test.ts +22 -0
  118. package/src/docker/stack-id.ts +38 -4
  119. package/src/docker/types.ts +15 -0
  120. package/src/drivers/ai-ollama.integration.test.ts +7 -4
  121. package/src/drivers/signal-engine.ts +10 -0
  122. package/src/drivers/signal-postgres.ts +24 -2
  123. package/src/drivers/signal-types.ts +10 -0
  124. package/src/elements/channel/fallback.test.ts +91 -0
  125. package/src/elements/channel/hard-bounce.test.ts +82 -0
  126. package/src/elements/channel/injection.test.ts +153 -0
  127. package/src/elements/channel/locale-ar.test.ts +102 -0
  128. package/src/elements/channel/otp-delivery.ts +5 -2
  129. package/src/elements/channel/receipts-ledger.test.ts +100 -0
  130. package/src/elements/channel/runtime.ts +24 -1
  131. package/src/elements/channel/test-helpers.ts +60 -0
  132. package/src/elements/channel/whatsapp-compliance.test.ts +124 -0
  133. package/src/elements/store/cache.ts +1 -1
  134. package/src/elements/store/emit-drizzle.ts +43 -9
  135. package/src/elements/store/runtime.ts +1 -1
  136. package/src/elements/store/schema-decl.test.ts +2 -0
  137. package/src/elements/store/schema-decl.ts +15 -0
  138. package/src/elements/store/seed.ts +1 -1
  139. package/src/index.ts +1 -0
  140. package/src/kernel/app.ts +117 -10
  141. package/src/kernel/boot-bind/signal.ts +6 -2
  142. package/src/kernel/boot-bind/store.ts +2 -1
  143. package/src/kernel/boot.test.ts +6 -5
  144. package/src/kernel/boot.ts +8 -3
  145. package/src/kernel/compensate.test.ts +131 -0
  146. package/src/kernel/correlation.test.ts +108 -0
  147. package/src/kernel/effect-timing.test.ts +82 -0
  148. package/src/kernel/flow.ts +38 -0
  149. package/src/kernel/fx-runs.test.ts +49 -0
  150. package/src/kernel/fx.test.ts +14 -0
  151. package/src/kernel/fx.ts +111 -1
  152. package/src/manifest/types.ts +8 -2
  153. package/src/runs/collect.ts +3 -0
  154. package/src/runs/export-otlp.ts +64 -0
  155. package/src/runs/index.ts +16 -0
  156. package/src/runs/parquet.ts +9 -0
  157. package/src/runs/types.ts +5 -0
  158. package/src/runs/window.test.ts +68 -0
  159. package/src/runs/window.ts +148 -0
  160. package/src/runtime/boot-warn.test.ts +45 -0
  161. package/src/runtime/boot-warn.ts +17 -0
  162. package/src/runtime/dev-request-log.ts +5 -5
  163. package/src/term.test.ts +95 -10
  164. package/src/term.ts +369 -14
  165. package/src/console/ui/dist/assets/panel-overview-DONDzBd2.js +0 -1
  166. package/src/console/ui/dist/assets/panel-runs-D5zf-D9c.js +0 -1
@@ -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
+ });
@@ -73,6 +73,20 @@ export interface FlowOptions<I = unknown, O = unknown, E extends FlowErrorMap =
73
73
  * (`oke doctor --diff` / CI gate).
74
74
  */
75
75
  readonly breaking?: boolean;
76
+ /**
77
+ * Compensation phase after terminal failure on a durable flow.
78
+ * Runs under the same journal session before `commit("failed")`.
79
+ * Bodies must use distinct `fx.step("undo:…")` names — never reuse
80
+ * forward step names. Never called on retryable attempts or sleep park.
81
+ */
82
+ readonly compensate?: (
83
+ ctx: {
84
+ readonly input: I;
85
+ readonly error: unknown;
86
+ readonly completedSteps: readonly string[];
87
+ },
88
+ fx: import("./fx.ts").Fx,
89
+ ) => unknown | Promise<unknown>;
76
90
  /** The behavior. */
77
91
  readonly do: FlowHandler<I, O>;
78
92
  }
@@ -165,6 +179,20 @@ export interface FlowDef<
165
179
  readonly plane: FlowPlane | undefined;
166
180
  /** Intentional contract-break acknowledgement for Manifest Diff. */
167
181
  readonly breaking: boolean;
182
+ /**
183
+ * Optional compensation phase after terminal durable failure.
184
+ * See {@link FlowOptions.compensate}.
185
+ */
186
+ readonly compensate:
187
+ | ((
188
+ ctx: {
189
+ readonly input: I;
190
+ readonly error: unknown;
191
+ readonly completedSteps: readonly string[];
192
+ },
193
+ fx: import("./fx.ts").Fx,
194
+ ) => unknown | Promise<unknown>)
195
+ | undefined;
168
196
  /** Handler body. */
169
197
  readonly do: FlowHandler<I, O>;
170
198
  /** Triggers bound via {@link on} (zero or more). */
@@ -236,6 +264,16 @@ export function flow<Opts extends FlowOptions<any, any, any>>(
236
264
  slo: options.slo,
237
265
  plane: options.plane,
238
266
  breaking: options.breaking ?? false,
267
+ compensate: options.compensate as
268
+ | ((
269
+ ctx: {
270
+ readonly input: InferFlowIn<Opts>;
271
+ readonly error: unknown;
272
+ readonly completedSteps: readonly string[];
273
+ },
274
+ fx: import("./fx.ts").Fx,
275
+ ) => unknown | Promise<unknown>)
276
+ | undefined,
239
277
  do: options.do as FlowHandler<InferFlowIn<Opts>, InferFlowOut<Opts>>,
240
278
  triggers,
241
279
  $trigger: undefined,
@@ -0,0 +1,49 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createRunsRuntime } from "../runs/runtime.ts";
3
+ import type { WideEvent } from "../runs/types.ts";
4
+ import { createFxContext } from "./fx.ts";
5
+
6
+ describe("fx.runs", () => {
7
+ test("window + checkSlo over bound runs runtime", async () => {
8
+ const runs = createRunsRuntime({ driver: "memory" });
9
+ await runs.open();
10
+ const now = Date.now();
11
+ const event: WideEvent = {
12
+ id: "r1",
13
+ flow: "checkout.create",
14
+ trigger: "http",
15
+ plane: "user",
16
+ gates: [],
17
+ cache: "none",
18
+ error: null,
19
+ effects: [],
20
+ logs: [],
21
+ durationMs: 250,
22
+ startedAt: now - 1_000,
23
+ endedAt: now,
24
+ dimensions: {},
25
+ };
26
+ await runs.append(event);
27
+
28
+ const { fx } = createFxContext({
29
+ flow: "ops.slo-check",
30
+ effects: { reads: ["runs"] },
31
+ runsRuntime: runs,
32
+ now: () => now,
33
+ });
34
+
35
+ const stats = await fx.runs.window("checkout.create", 5 * 60_000);
36
+ expect(stats.total).toBe(1);
37
+ expect(stats.p95Ms).toBe(250);
38
+
39
+ const breaches = await fx.runs.checkSlo(
40
+ "checkout.create",
41
+ { latency: { p95: "100ms" } },
42
+ 5 * 60_000,
43
+ );
44
+ expect(breaches).toHaveLength(1);
45
+ expect(breaches[0]!.kind).toBe("latency_p95");
46
+
47
+ await runs.close();
48
+ });
49
+ });
@@ -120,6 +120,20 @@ describe("fx — wholesale swap", () => {
120
120
  store() {
121
121
  throw new Error("store must not be used");
122
122
  },
123
+ runs: {
124
+ query: async () => {
125
+ throw new Error("runs must not be used");
126
+ },
127
+ all: async () => {
128
+ throw new Error("runs must not be used");
129
+ },
130
+ window: async () => {
131
+ throw new Error("runs must not be used");
132
+ },
133
+ checkSlo: async () => {
134
+ throw new Error("runs must not be used");
135
+ },
136
+ },
123
137
  async emit(signal) {
124
138
  calls.push(`emit:${typeof signal === "string" ? signal : signal.name}`);
125
139
  },
package/src/kernel/fx.ts CHANGED
@@ -52,9 +52,20 @@ import {
52
52
  import { maskRedactedDeep, Redacted } from "./redacted.ts";
53
53
  import type { JournalSession } from "./journal.ts";
54
54
  import type { RunTelemetry } from "./run-telemetry.ts";
55
+ import type { RunsRuntime } from "../runs/runtime.ts";
56
+ import type { RunsRow, WideEvent } from "../runs/types.ts";
57
+ import {
58
+ evaluateSloBreaches,
59
+ windowStatsForFlow,
60
+ type RunWindowStats,
61
+ type SloBreach,
62
+ } from "../runs/window.ts";
55
63
  import { translate, type MessageCatalogs } from "../i18n/messages.ts";
56
64
  import type { AppMessageKey, MessageValues } from "../i18n/types.ts";
57
65
 
66
+ /** Resource ref Flows declare to read the Runs store via {@link Fx.runs}. */
67
+ export const RUNS_RESOURCE = "runs";
68
+
58
69
  export type { FxRetryOptions, FxThunk } from "./concurrency.ts";
59
70
 
60
71
  /** Named ref: plain string or `{ name }` element handle. */
@@ -327,6 +338,45 @@ export interface FxJson {
327
338
  with<T>(data: T, meta: Record<string, unknown>): JsonResult<T>;
328
339
  }
329
340
 
341
+ /**
342
+ * Flow-facing read door to the Runs wide-event store.
343
+ *
344
+ * Declare `effects: { reads: ["runs"] }`. Powers native SLO checkers
345
+ * (Clock + Channel) without a parallel `fx.metric` API.
346
+ */
347
+ export interface FxRuns {
348
+ /**
349
+ * Run SQL against the Runs store (`FROM runs` for files/memory).
350
+ *
351
+ * @param sql - Driver SQL
352
+ */
353
+ query(sql: string): Promise<RunsRow[]>;
354
+ /** Materialise all visible wide events (small stores / tests). */
355
+ all(): Promise<WideEvent[]>;
356
+ /**
357
+ * Rolling P95 / success-rate stats for one flow over a window.
358
+ *
359
+ * @param flow - Flow name
360
+ * @param windowMs - Lookback window (default 5 minutes)
361
+ */
362
+ window(flow: string, windowMs?: number): Promise<RunWindowStats>;
363
+ /**
364
+ * Evaluate Manifest-style SLO thresholds against a rolling window.
365
+ *
366
+ * @param flow - Flow name
367
+ * @param slo - Availability / latency thresholds
368
+ * @param windowMs - Lookback window (default 5 minutes)
369
+ */
370
+ checkSlo(
371
+ flow: string,
372
+ slo: {
373
+ readonly availability?: string;
374
+ readonly latency?: { readonly p95?: string; readonly p99?: string };
375
+ },
376
+ windowMs?: number,
377
+ ): Promise<readonly SloBreach[]>;
378
+ }
379
+
330
380
  /**
331
381
  * The `fx` context object — v1 surface.
332
382
  *
@@ -362,6 +412,11 @@ export interface Fx {
362
412
  * @param input - Input payload
363
413
  */
364
414
  call(flow: NamedRef, input?: unknown): Promise<unknown>;
415
+ /**
416
+ * Query the Runs wide-event store (records `read` on `"runs"`).
417
+ * Requires a bound runs runtime and `effects.reads` including `"runs"`.
418
+ */
419
+ readonly runs: FxRuns;
365
420
  /** Clock surface. */
366
421
  readonly clock: FxClock;
367
422
  /**
@@ -589,6 +644,11 @@ export interface CreateFxOptions {
589
644
  * configured driver (postgres = same transaction as store writes).
590
645
  */
591
646
  readonly signalRuntime?: SignalRuntime;
647
+ /**
648
+ * Optional runs runtime. When set, `fx.runs` queries wide events for
649
+ * native SLO checkers (Clock + Channel alerting).
650
+ */
651
+ readonly runsRuntime?: RunsRuntime;
592
652
  /**
593
653
  * Optional vault runtime. When set, `fx.vault` reads through it and
594
654
  * `fx.log` redacts loaded secret values automatically.
@@ -610,6 +670,11 @@ export interface CreateFxOptions {
610
670
  * with zero flow instrumentation.
611
671
  */
612
672
  readonly runTelemetry?: RunTelemetry;
673
+ /**
674
+ * This invocation's WideEvent / run id. Stamped onto `fx.emit` messages
675
+ * as `parentRunId` so consuming Flows can join the trace chain.
676
+ */
677
+ readonly runId?: string;
613
678
  /** Reveal PII through the store runtime (requires `pii:reveal` upstream). */
614
679
  readonly revealPii?: boolean;
615
680
  /**
@@ -1135,13 +1200,58 @@ export function createFxContext(options: CreateFxOptions): FxContext {
1135
1200
  },
1136
1201
  };
1137
1202
 
1203
+ const runsSurface: FxRuns = {
1204
+ query(sql) {
1205
+ return gated("read", RUNS_RESOURCE, async () => {
1206
+ if (!options.runsRuntime) {
1207
+ throw new Error("fx.runs.query requires a bound runs runtime (oke({ runs }))");
1208
+ }
1209
+ return options.runsRuntime.query(sql);
1210
+ });
1211
+ },
1212
+ all() {
1213
+ return gated("read", RUNS_RESOURCE, async () => {
1214
+ if (!options.runsRuntime) {
1215
+ throw new Error("fx.runs.all requires a bound runs runtime (oke({ runs }))");
1216
+ }
1217
+ return options.runsRuntime.all();
1218
+ });
1219
+ },
1220
+ async window(flowName, windowMs = 5 * 60_000) {
1221
+ return gated("read", RUNS_RESOURCE, async () => {
1222
+ if (!options.runsRuntime) {
1223
+ throw new Error("fx.runs.window requires a bound runs runtime (oke({ runs }))");
1224
+ }
1225
+ const events = await options.runsRuntime.all();
1226
+ return windowStatsForFlow(events, flowName, now(), windowMs);
1227
+ });
1228
+ },
1229
+ async checkSlo(flowName, slo, windowMs = 5 * 60_000) {
1230
+ return gated("read", RUNS_RESOURCE, async () => {
1231
+ if (!options.runsRuntime) {
1232
+ throw new Error("fx.runs.checkSlo requires a bound runs runtime (oke({ runs }))");
1233
+ }
1234
+ const events = await options.runsRuntime.all();
1235
+ const stats = windowStatsForFlow(events, flowName, now(), windowMs);
1236
+ return evaluateSloBreaches(stats, slo);
1237
+ });
1238
+ },
1239
+ };
1240
+
1138
1241
  const fx: Fx = {
1139
1242
  store: storeHandle,
1243
+ runs: runsSurface,
1140
1244
  emit(signal, payload, emitOptions) {
1141
1245
  const name = resolveName(signal);
1142
1246
  return gated("emit", name, async () => {
1143
1247
  if (options.signalRuntime) {
1144
- await options.signalRuntime.emit(name, payload, emitOptions);
1248
+ const merged: SignalEmitOptions = {
1249
+ ...emitOptions,
1250
+ ...(options.runId !== undefined && emitOptions?.parentRunId === undefined
1251
+ ? { parentRunId: options.runId }
1252
+ : {}),
1253
+ };
1254
+ await options.signalRuntime.emit(name, payload, merged);
1145
1255
  }
1146
1256
  });
1147
1257
  },
@@ -40,6 +40,12 @@ export type ChannelMedium = "email" | "sms" | "whatsapp" | "push" | "any";
40
40
  /** Resource ref: `sql:table`, `kv:namespace`, `files:bucket`, `index:name`. */
41
41
  export type ResourceRef = `${StoreFacet}:${string}`;
42
42
 
43
+ /**
44
+ * Observability read capability for `fx.runs` — not a store facet.
45
+ * Declare on `effects.reads` (never `writes`).
46
+ */
47
+ export type RunsResourceRef = "runs";
48
+
43
49
  /** Signal name reference. */
44
50
  export type SignalRef = string;
45
51
 
@@ -68,8 +74,8 @@ export type JsonSchema = string | Record<string, unknown>;
68
74
  * `sends` / `asks` are irreversible (asks also nondeterministic + cost).
69
75
  */
70
76
  export interface Effects {
71
- /** Store reads. */
72
- reads?: ResourceRef[];
77
+ /** Store reads, plus optional `"runs"` for `fx.runs`. */
78
+ reads?: Array<ResourceRef | RunsResourceRef>;
73
79
  /** Store writes. */
74
80
  writes?: ResourceRef[];
75
81
  /** Emitted signals. */
@@ -32,6 +32,8 @@ export interface CollectWideEventInput {
32
32
  readonly endedAt: number;
33
33
  /** Pipeline failure when present. */
34
34
  readonly failure?: FlowFailure | null;
35
+ /** Validated flow input (for local replay). */
36
+ readonly input?: unknown;
35
37
  /** Optional parent run id (causal chain). */
36
38
  readonly parentId?: string;
37
39
  /** Optional run id (generated when omitted). */
@@ -104,6 +106,7 @@ export function collectWideEvent(input: CollectWideEventInput): WideEvent {
104
106
  : {}),
105
107
  ...(input.buildVersion !== undefined ? { buildVersion: input.buildVersion } : {}),
106
108
  error,
109
+ ...(input.input !== undefined ? { input: input.input } : {}),
107
110
  effects: [...input.ledger.entries],
108
111
  logs: [...input.telemetry.logs],
109
112
  durationMs,