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
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Boot-level proof: compiled/inferred effects reaching a REAL running app's
3
+ * capability token — not an isolated compiler-only or kernel-only test.
4
+ *
5
+ * Before this file existed, `flow({...})` with no hand-declared `effects`
6
+ * always minted an OPEN capability token (every access allowed, no gate at
7
+ * all) in every environment, including `docker` / `prod` — `extractManifest`
8
+ * inference only ever fed the static `manifest.oke.json` artifact (Console,
9
+ * docs, publish), never the live boot path. Confirmed via real `oke()` boot
10
+ * + `app.fetch()`, not assumption.
11
+ *
12
+ * Also documents the sql:<table> (compiler inference) vs sql:<store-name>
13
+ * (kernel `gatedSqlHandle`) naming mismatch this stamping bridge surfaces:
14
+ * once inference actually reaches the runtime, a flow relying on it for a
15
+ * real `fx.store(db).insert(table)` write must not throw `UNDECLARED_WRITE`
16
+ * for touching the exact table it declared.
17
+ */
18
+
19
+ import { afterEach, describe, expect, test } from "bun:test";
20
+ import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises";
21
+ import { tmpdir } from "node:os";
22
+ import { join } from "node:path";
23
+ import { z } from "zod";
24
+ import { gate } from "../elements/gate.ts";
25
+ import { field, id, now, store } from "../elements/store.ts";
26
+ import { oke } from "./app.ts";
27
+ import { resetNoEffectsWarnForTests } from "./boot.ts";
28
+ import { flow, resetFlowSeq } from "./flow.ts";
29
+ import { on, resetBindings } from "./on.ts";
30
+ import { createTestApp } from "../test/create-test-app.ts";
31
+ import { http } from "./triggers.ts";
32
+
33
+ afterEach(() => {
34
+ resetBindings();
35
+ resetFlowSeq();
36
+ resetNoEffectsWarnForTests();
37
+ });
38
+
39
+ const notes = store.schema.table("notes", {
40
+ id: field.text().primaryKey().defaultFn(id),
41
+ title: field.text().notNull(),
42
+ createdAt: field.integer().notNull().defaultFn(now),
43
+ });
44
+
45
+ const CreateIn = z.object({ id: z.string(), title: z.string() });
46
+ const CreateOut = z.object({ ok: z.boolean() });
47
+
48
+ function buildUnannotatedCreateFlow(db: ReturnType<typeof store.sql>) {
49
+ return on(
50
+ http.post("/notes").gate(gate.public),
51
+ flow({
52
+ name: "notes.create",
53
+ in: CreateIn,
54
+ out: CreateOut,
55
+ // Deliberately no `effects` — the "let the compiler infer it" case.
56
+ do: async (input, fx) => {
57
+ await fx.store(db).insert(notes).values({ id: input.id, title: input.title, createdAt: 1 });
58
+ return { ok: true };
59
+ },
60
+ }),
61
+ );
62
+ }
63
+
64
+ describe("boot-level: undeclared-effects flow, no manifest / rootDir", () => {
65
+ test("local / test: open token — real insert succeeds (documented dev-loop fallback)", async () => {
66
+ resetBindings();
67
+ resetFlowSeq();
68
+ const db = store.sql("app", { schema: { notes } });
69
+ // `on(...)` (inside buildUnannotatedCreateFlow) must run BEFORE `oke(...)`
70
+ // is constructed — oke() synchronously drains the global on() registry
71
+ // at construction time; a binding created only as .adopt()'s argument
72
+ // (after oke() already ran) never gets wired to a route.
73
+ const create = buildUnannotatedCreateFlow(db);
74
+ const app = oke({ name: "stamp-open", gate: { policies: [gate.public] } }).adopt({ create });
75
+ Object.assign(app.$options, { stores: [db] });
76
+ await createTestApp(app); // createTestApp always boots env: "test"
77
+
78
+ const res = await app.fetch(
79
+ new Request("http://localhost/notes", {
80
+ method: "POST",
81
+ headers: { "content-type": "application/json" },
82
+ body: JSON.stringify({ id: "x1", title: "hi" }),
83
+ }),
84
+ );
85
+ expect(res.status).toBe(200);
86
+ });
87
+
88
+ test("docker: hard fail at boot — OKE1008, never an open token in a deploy-shaped env", async () => {
89
+ resetBindings();
90
+ resetFlowSeq();
91
+ const db = store.sql("app", { schema: { notes } });
92
+ const create = buildUnannotatedCreateFlow(db);
93
+ const app = oke({ name: "stamp-docker", gate: { policies: [gate.public] } }).adopt({ create });
94
+
95
+ await expect(
96
+ app.boot({ env: "docker", stores: [db], unguardedHttp: "allow", startScheduler: false }),
97
+ ).rejects.toThrow(/OKE1008/);
98
+ });
99
+
100
+ test("prod: hard fail at boot — same posture as docker", async () => {
101
+ resetBindings();
102
+ resetFlowSeq();
103
+ const db = store.sql("app", { schema: { notes } });
104
+ const create = buildUnannotatedCreateFlow(db);
105
+ const app = oke({ name: "stamp-prod", gate: { policies: [gate.public] } }).adopt({ create });
106
+
107
+ await expect(
108
+ app.boot({ env: "prod", stores: [db], unguardedHttp: "allow", startScheduler: false }),
109
+ ).rejects.toThrow(/OKE1008/);
110
+ });
111
+ });
112
+
113
+ describe("boot-level: table-ref resolution stays backward compatible", () => {
114
+ test("older store-level effects (sql:<store>) still cover a schema-table op — every existing template's convention", async () => {
115
+ resetBindings();
116
+ resetFlowSeq();
117
+ const db = store.sql("app", { schema: { notes } });
118
+ const create = on(
119
+ http.post("/notes").gate(gate.public),
120
+ flow({
121
+ name: "notes.create",
122
+ in: CreateIn,
123
+ out: CreateOut,
124
+ // The convention every template/test predates Direction B with —
125
+ // store-level, not table-level. Must keep working unchanged.
126
+ effects: { writes: ["sql:app"] },
127
+ do: async (input, fx) => {
128
+ await fx
129
+ .store(db)
130
+ .insert(notes)
131
+ .values({ id: input.id, title: input.title, createdAt: 1 });
132
+ return { ok: true };
133
+ },
134
+ }),
135
+ );
136
+ const app = oke({ name: "stamp-back-compat", gate: { policies: [gate.public] } }).adopt({
137
+ create,
138
+ });
139
+ Object.assign(app.$options, { stores: [db] });
140
+ await createTestApp(app);
141
+
142
+ const res = await app.fetch(
143
+ new Request("http://localhost/notes", {
144
+ method: "POST",
145
+ headers: { "content-type": "application/json" },
146
+ body: JSON.stringify({ id: "x1", title: "hi" }),
147
+ }),
148
+ );
149
+ expect(res.status).toBe(200);
150
+ });
151
+ });
152
+
153
+ describe("boot-level: undeclared-effects flow, rootDir stamping from a real source tree", () => {
154
+ test("real fx.store(db).insert(table) write succeeds once inference is stamped onto the capability token", async () => {
155
+ resetBindings();
156
+ resetFlowSeq();
157
+ const dir = await mkdtemp(join(tmpdir(), "oke-stamp-"));
158
+ try {
159
+ await mkdir(join(dir, "flows", "notes"), { recursive: true });
160
+ // A real source file with NO manual `effects:` — extractManifest infers
161
+ // `writes: ["sql:notes"]` for this from the real `fx.store(db).insert`
162
+ // call, exactly like the kernel test flow above.
163
+ await writeFile(
164
+ join(dir, "flows", "notes", "index.ts"),
165
+ `
166
+ import { on, flow, http, gate } from "okengine";
167
+ // Unresolved bindings are fine — extraction is AST-only, never executed.
168
+ // What matters is the literal shape: fx.store(db).insert(notes) so the
169
+ // same table-name inference that produced "sql:notes" earlier fires here.
170
+ const db = {} as any;
171
+ const notes = {} as any;
172
+ export const create = on(
173
+ http.post("/notes").gate(gate.public),
174
+ flow({
175
+ name: "notes.create",
176
+ do: async (input, fx) => {
177
+ await fx.store(db).insert(notes).values({ id: input.id, title: input.title, createdAt: 1 });
178
+ return { ok: true };
179
+ },
180
+ }),
181
+ );
182
+ `,
183
+ );
184
+
185
+ const db = store.sql("app", { schema: { notes } });
186
+ const create = buildUnannotatedCreateFlow(db);
187
+ const app = oke({ name: "stamp-rootdir", gate: { policies: [gate.public] } }).adopt({
188
+ create,
189
+ });
190
+ Object.assign(app.$options, { stores: [db] });
191
+ await createTestApp(app, { boot: { rootDir: dir } });
192
+
193
+ const res = await app.fetch(
194
+ new Request("http://localhost/notes", {
195
+ method: "POST",
196
+ headers: { "content-type": "application/json" },
197
+ body: JSON.stringify({ id: "x1", title: "hi" }),
198
+ }),
199
+ );
200
+ const body = (await res.json()) as { data: unknown; error: { message?: string } | null };
201
+ expect(res.status, body.error?.message ?? "").toBe(200);
202
+ expect(body.data).toEqual({ ok: true });
203
+ } finally {
204
+ await rm(dir, { recursive: true, force: true });
205
+ }
206
+ });
207
+ });
@@ -142,6 +142,17 @@ export const OKE_ERRORS = {
142
142
  cause: 'Flow "{flow}" calls "{resource}" without declaring it.',
143
143
  fix: 'Add "{resource}" to this flow\'s effects.calls.',
144
144
  },
145
+ /**
146
+ * Flow has no declared `effects` and no Manifest-derived effects were
147
+ * available to stamp at boot (docker / prod — never a silent open token).
148
+ */
149
+ NO_EFFECTS_DECLARED: {
150
+ code: 1008,
151
+ cause: 'Flow "{flow}" has no declared effects and no Manifest to derive them from.',
152
+ fix:
153
+ "Add explicit `effects` to this flow, or boot with a Manifest (`oke build`) / " +
154
+ "`rootDir` so effects can be derived. docker/prod refuse an open capability token.",
155
+ },
145
156
  /**
146
157
  * Emit target has no subscriber (unified-theory §21).
147
158
  * Thrown at emit when `optional` is false and nobody is subscribed.
@@ -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
@@ -10,6 +10,7 @@
10
10
  */
11
11
 
12
12
  import type { Effects, ResourceRef } from "../manifest/types.ts";
13
+ import { schemaTableName, sqlTableRef } from "../manifest/sql-resource.ts";
13
14
  import type {
14
15
  FilesStoreDecl,
15
16
  FilesStoreFxHandle,
@@ -52,9 +53,20 @@ import {
52
53
  import { maskRedactedDeep, Redacted } from "./redacted.ts";
53
54
  import type { JournalSession } from "./journal.ts";
54
55
  import type { RunTelemetry } from "./run-telemetry.ts";
56
+ import type { RunsRuntime } from "../runs/runtime.ts";
57
+ import type { RunsRow, WideEvent } from "../runs/types.ts";
58
+ import {
59
+ evaluateSloBreaches,
60
+ windowStatsForFlow,
61
+ type RunWindowStats,
62
+ type SloBreach,
63
+ } from "../runs/window.ts";
55
64
  import { translate, type MessageCatalogs } from "../i18n/messages.ts";
56
65
  import type { AppMessageKey, MessageValues } from "../i18n/types.ts";
57
66
 
67
+ /** Resource ref Flows declare to read the Runs store via {@link Fx.runs}. */
68
+ export const RUNS_RESOURCE = "runs";
69
+
58
70
  export type { FxRetryOptions, FxThunk } from "./concurrency.ts";
59
71
 
60
72
  /** Named ref: plain string or `{ name }` element handle. */
@@ -327,6 +339,45 @@ export interface FxJson {
327
339
  with<T>(data: T, meta: Record<string, unknown>): JsonResult<T>;
328
340
  }
329
341
 
342
+ /**
343
+ * Flow-facing read door to the Runs wide-event store.
344
+ *
345
+ * Declare `effects: { reads: ["runs"] }`. Powers native SLO checkers
346
+ * (Clock + Channel) without a parallel `fx.metric` API.
347
+ */
348
+ export interface FxRuns {
349
+ /**
350
+ * Run SQL against the Runs store (`FROM runs` for files/memory).
351
+ *
352
+ * @param sql - Driver SQL
353
+ */
354
+ query(sql: string): Promise<RunsRow[]>;
355
+ /** Materialise all visible wide events (small stores / tests). */
356
+ all(): Promise<WideEvent[]>;
357
+ /**
358
+ * Rolling P95 / success-rate stats for one flow over a window.
359
+ *
360
+ * @param flow - Flow name
361
+ * @param windowMs - Lookback window (default 5 minutes)
362
+ */
363
+ window(flow: string, windowMs?: number): Promise<RunWindowStats>;
364
+ /**
365
+ * Evaluate Manifest-style SLO thresholds against a rolling window.
366
+ *
367
+ * @param flow - Flow name
368
+ * @param slo - Availability / latency thresholds
369
+ * @param windowMs - Lookback window (default 5 minutes)
370
+ */
371
+ checkSlo(
372
+ flow: string,
373
+ slo: {
374
+ readonly availability?: string;
375
+ readonly latency?: { readonly p95?: string; readonly p99?: string };
376
+ },
377
+ windowMs?: number,
378
+ ): Promise<readonly SloBreach[]>;
379
+ }
380
+
330
381
  /**
331
382
  * The `fx` context object — v1 surface.
332
383
  *
@@ -362,6 +413,11 @@ export interface Fx {
362
413
  * @param input - Input payload
363
414
  */
364
415
  call(flow: NamedRef, input?: unknown): Promise<unknown>;
416
+ /**
417
+ * Query the Runs wide-event store (records `read` on `"runs"`).
418
+ * Requires a bound runs runtime and `effects.reads` including `"runs"`.
419
+ */
420
+ readonly runs: FxRuns;
365
421
  /** Clock surface. */
366
422
  readonly clock: FxClock;
367
423
  /**
@@ -589,6 +645,11 @@ export interface CreateFxOptions {
589
645
  * configured driver (postgres = same transaction as store writes).
590
646
  */
591
647
  readonly signalRuntime?: SignalRuntime;
648
+ /**
649
+ * Optional runs runtime. When set, `fx.runs` queries wide events for
650
+ * native SLO checkers (Clock + Channel alerting).
651
+ */
652
+ readonly runsRuntime?: RunsRuntime;
592
653
  /**
593
654
  * Optional vault runtime. When set, `fx.vault` reads through it and
594
655
  * `fx.log` redacts loaded secret values automatically.
@@ -610,6 +671,11 @@ export interface CreateFxOptions {
610
671
  * with zero flow instrumentation.
611
672
  */
612
673
  readonly runTelemetry?: RunTelemetry;
674
+ /**
675
+ * This invocation's WideEvent / run id. Stamped onto `fx.emit` messages
676
+ * as `parentRunId` so consuming Flows can join the trace chain.
677
+ */
678
+ readonly runId?: string;
613
679
  /** Reveal PII through the store runtime (requires `pii:reveal` upstream). */
614
680
  readonly revealPii?: boolean;
615
681
  /**
@@ -787,6 +853,34 @@ export function createFxContext(options: CreateFxOptions): FxContext {
787
853
  );
788
854
  }
789
855
  };
856
+ /**
857
+ * Gate a table-scoped SQL operation. Prefers the precise `sql:<table>`
858
+ * ref (matches what the compiler's AST inference derives from the same
859
+ * call site — {@link "../manifest/sql-resource.ts"}); falls back to the
860
+ * store-level ref when the table ref isn't declared — every flow that
861
+ * hand-declared the older `effects: { writes: ["sql:<store>"] }`
862
+ * convention (every existing template, `upsert-app.test.ts`, …) must
863
+ * keep working unchanged. Ledger / journal record whichever ref the
864
+ * capability check actually matched, not always the coarser one.
865
+ *
866
+ * @param kind - Effect kind
867
+ * @param table - Table argument passed to a `SqlStoreHandle` method
868
+ * @param body - Work to run under the gate
869
+ */
870
+ const gatedTable = <T>(
871
+ kind: Parameters<CapabilityToken["assert"]>[0],
872
+ table: unknown,
873
+ body: () => T | Promise<T>,
874
+ ): Promise<T> => {
875
+ const name = schemaTableName(table);
876
+ if (name !== undefined) {
877
+ const perTable = sqlTableRef(name);
878
+ if (perTable !== ref && capability.allows(kind, perTable)) {
879
+ return gated(kind, perTable, body);
880
+ }
881
+ }
882
+ return gated(kind, ref, body);
883
+ };
790
884
 
791
885
  return {
792
886
  ref,
@@ -805,7 +899,7 @@ export function createFxContext(options: CreateFxOptions): FxContext {
805
899
  limit?: number;
806
900
  offset?: number;
807
901
  }): Promise<SqlRow[]> =>
808
- gated("read", ref, async () => {
902
+ gatedTable("read", table, async () => {
809
903
  const h = await ensure();
810
904
  const from = h.select(columns).from(table);
811
905
  const filtered = plan.where === undefined ? from : from.where(plan.where);
@@ -855,14 +949,14 @@ export function createFxContext(options: CreateFxOptions): FxContext {
855
949
  return {
856
950
  values(row) {
857
951
  const runExecute = () =>
858
- gated("write", ref, async () => {
952
+ gatedTable("write", table, async () => {
859
953
  refuseDryRunWrite();
860
954
  const h = await ensure();
861
955
  await h.insert(table).values(row).execute();
862
956
  });
863
957
  return {
864
958
  returning() {
865
- return gated("write", ref, async () => {
959
+ return gatedTable("write", table, async () => {
866
960
  refuseDryRunWrite();
867
961
  const h = await ensure();
868
962
  return h.insert(table).values(row).returning();
@@ -881,7 +975,7 @@ export function createFxContext(options: CreateFxOptions): FxContext {
881
975
  set(row) {
882
976
  return {
883
977
  where(where) {
884
- return gated("write", ref, async () => {
978
+ return gatedTable("write", table, async () => {
885
979
  refuseDryRunWrite();
886
980
  const h = await ensure();
887
981
  return h.update(table).set(row).where(where);
@@ -892,14 +986,14 @@ export function createFxContext(options: CreateFxOptions): FxContext {
892
986
  };
893
987
  },
894
988
  findById(table, id) {
895
- return gated("read", ref, async () => {
989
+ return gatedTable("read", table, async () => {
896
990
  const h = await ensure();
897
991
  return h.findById(table, id);
898
992
  });
899
993
  },
900
994
  delete(table: Parameters<SqlStoreHandle["delete"]>[0], id?: string) {
901
995
  if (id !== undefined) {
902
- return gated("write", ref, async () => {
996
+ return gatedTable("write", table, async () => {
903
997
  refuseDryRunWrite();
904
998
  const h = await ensure();
905
999
  return h.delete(table, id);
@@ -907,7 +1001,7 @@ export function createFxContext(options: CreateFxOptions): FxContext {
907
1001
  }
908
1002
  return {
909
1003
  where(where: unknown) {
910
- return gated("write", ref, async () => {
1004
+ return gatedTable("write", table, async () => {
911
1005
  refuseDryRunWrite();
912
1006
  const h = await ensure();
913
1007
  return h.delete(table).where(where);
@@ -916,20 +1010,20 @@ export function createFxContext(options: CreateFxOptions): FxContext {
916
1010
  };
917
1011
  },
918
1012
  exists(table, idOrWhere) {
919
- return gated("read", ref, async () => {
1013
+ return gatedTable("read", table, async () => {
920
1014
  const h = await ensure();
921
1015
  return h.exists(table, idOrWhere);
922
1016
  });
923
1017
  },
924
1018
  upsert(table, matchOn, values, upsertOptions) {
925
- return gated("write", ref, async () => {
1019
+ return gatedTable("write", table, async () => {
926
1020
  refuseDryRunWrite();
927
1021
  const h = await ensure();
928
1022
  return h.upsert(table, matchOn, values, upsertOptions);
929
1023
  });
930
1024
  },
931
1025
  increment(table, id, column, by) {
932
- return gated("write", ref, async () => {
1026
+ return gatedTable("write", table, async () => {
933
1027
  refuseDryRunWrite();
934
1028
  const h = await ensure();
935
1029
  return h.increment(table, id, column, by);
@@ -942,19 +1036,19 @@ export function createFxContext(options: CreateFxOptions): FxContext {
942
1036
  });
943
1037
  },
944
1038
  count(table, where) {
945
- return gated("read", ref, async () => {
1039
+ return gatedTable("read", table, async () => {
946
1040
  const h = await ensure();
947
1041
  return h.count(table, where);
948
1042
  });
949
1043
  },
950
1044
  page(table, pageOptions) {
951
- return gated("read", ref, async () => {
1045
+ return gatedTable("read", table, async () => {
952
1046
  const h = await ensure();
953
1047
  return h.page(table, pageOptions);
954
1048
  });
955
1049
  },
956
1050
  ensureTable(table) {
957
- return gated("write", ref, async () => {
1051
+ return gatedTable("write", table, async () => {
958
1052
  refuseDryRunWrite();
959
1053
  const h = await ensure();
960
1054
  return h.ensureTable(table);
@@ -1135,13 +1229,58 @@ export function createFxContext(options: CreateFxOptions): FxContext {
1135
1229
  },
1136
1230
  };
1137
1231
 
1232
+ const runsSurface: FxRuns = {
1233
+ query(sql) {
1234
+ return gated("read", RUNS_RESOURCE, async () => {
1235
+ if (!options.runsRuntime) {
1236
+ throw new Error("fx.runs.query requires a bound runs runtime (oke({ runs }))");
1237
+ }
1238
+ return options.runsRuntime.query(sql);
1239
+ });
1240
+ },
1241
+ all() {
1242
+ return gated("read", RUNS_RESOURCE, async () => {
1243
+ if (!options.runsRuntime) {
1244
+ throw new Error("fx.runs.all requires a bound runs runtime (oke({ runs }))");
1245
+ }
1246
+ return options.runsRuntime.all();
1247
+ });
1248
+ },
1249
+ async window(flowName, windowMs = 5 * 60_000) {
1250
+ return gated("read", RUNS_RESOURCE, async () => {
1251
+ if (!options.runsRuntime) {
1252
+ throw new Error("fx.runs.window requires a bound runs runtime (oke({ runs }))");
1253
+ }
1254
+ const events = await options.runsRuntime.all();
1255
+ return windowStatsForFlow(events, flowName, now(), windowMs);
1256
+ });
1257
+ },
1258
+ async checkSlo(flowName, slo, windowMs = 5 * 60_000) {
1259
+ return gated("read", RUNS_RESOURCE, async () => {
1260
+ if (!options.runsRuntime) {
1261
+ throw new Error("fx.runs.checkSlo requires a bound runs runtime (oke({ runs }))");
1262
+ }
1263
+ const events = await options.runsRuntime.all();
1264
+ const stats = windowStatsForFlow(events, flowName, now(), windowMs);
1265
+ return evaluateSloBreaches(stats, slo);
1266
+ });
1267
+ },
1268
+ };
1269
+
1138
1270
  const fx: Fx = {
1139
1271
  store: storeHandle,
1272
+ runs: runsSurface,
1140
1273
  emit(signal, payload, emitOptions) {
1141
1274
  const name = resolveName(signal);
1142
1275
  return gated("emit", name, async () => {
1143
1276
  if (options.signalRuntime) {
1144
- await options.signalRuntime.emit(name, payload, emitOptions);
1277
+ const merged: SignalEmitOptions = {
1278
+ ...emitOptions,
1279
+ ...(options.runId !== undefined && emitOptions?.parentRunId === undefined
1280
+ ? { parentRunId: options.runId }
1281
+ : {}),
1282
+ };
1283
+ await options.signalRuntime.emit(name, payload, merged);
1145
1284
  }
1146
1285
  });
1147
1286
  },