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.
- package/AGENTS.md +1 -1
- package/manifest.v1.schema.json +9 -1
- package/package.json +3 -3
- package/site/content/docs/console/runs.mdx +10 -0
- package/site/content/docs/console/traces.mdx +4 -0
- package/site/content/docs/elements/ai.mdx +54 -32
- package/site/content/docs/elements/channel.mdx +97 -9
- package/site/content/docs/elements/clock.mdx +1 -0
- package/site/content/docs/elements/flow.mdx +42 -8
- package/site/content/docs/get-started/basic-usage.mdx +5 -1
- package/site/content/docs/get-started/installation.mdx +9 -5
- package/site/content/docs/meta.json +2 -0
- package/site/content/docs/providers/azure-redis.mdx +85 -0
- package/site/content/docs/providers/cockroachdb.mdx +86 -0
- package/site/content/docs/providers/digitalocean-caching.mdx +88 -0
- package/site/content/docs/providers/dragonfly-cloud.mdx +82 -0
- package/site/content/docs/providers/elasticache.mdx +92 -0
- package/site/content/docs/providers/index.mdx +101 -0
- package/site/content/docs/providers/memorystore.mdx +89 -0
- package/site/content/docs/providers/meta.json +20 -0
- package/site/content/docs/providers/neon.mdx +115 -0
- package/site/content/docs/providers/redis-cloud.mdx +93 -0
- package/site/content/docs/providers/supabase.mdx +117 -0
- package/site/content/docs/providers/upstash.mdx +91 -0
- package/site/content/docs/providers/yugabytedb.mdx +95 -0
- package/site/content/docs/recipes/caddy.mdx +128 -0
- package/site/content/docs/recipes/cockroachdb.mdx +127 -0
- package/site/content/docs/recipes/dragonfly.mdx +125 -0
- package/site/content/docs/recipes/index.mdx +146 -0
- package/site/content/docs/recipes/llama-cpp.mdx +153 -0
- package/site/content/docs/recipes/mailpit.mdx +136 -0
- package/site/content/docs/recipes/meilisearch.mdx +139 -0
- package/site/content/docs/recipes/meta.json +31 -0
- package/site/content/docs/recipes/ollama.mdx +142 -0
- package/site/content/docs/recipes/openbao.mdx +141 -0
- package/site/content/docs/recipes/pgdog.mdx +137 -0
- package/site/content/docs/recipes/postgres.mdx +136 -0
- package/site/content/docs/recipes/redis.mdx +130 -0
- package/site/content/docs/recipes/rustfs.mdx +148 -0
- package/site/content/docs/recipes/sglang.mdx +105 -0
- package/site/content/docs/recipes/supabase-docker.mdx +145 -0
- package/site/content/docs/recipes/timescale.mdx +129 -0
- package/site/content/docs/recipes/traefik.mdx +136 -0
- package/site/content/docs/recipes/valkey.mdx +121 -0
- package/site/content/docs/recipes/vllm.mdx +106 -0
- package/site/content/docs/recipes/yugabytedb.mdx +135 -0
- package/site/content/docs/reference/cli.md +42 -6
- package/site/content/docs/reference/configuration.mdx +5 -3
- package/site/content/docs/reference/environment-variables.mdx +12 -12
- package/site/content/docs/reference/fx.mdx +38 -4
- package/src/cli/ai-setup/ai-setup.test.ts +32 -12
- package/src/cli/ai-setup/apply.ts +65 -23
- package/src/cli/ai-setup/catalog.ts +1316 -35
- package/src/cli/ai-setup/detect-ollama.ts +47 -0
- package/src/cli/ai-setup/index.ts +44 -7
- package/src/cli/ai-setup/prompts.ts +430 -486
- package/src/cli/ai-setup/recommend.test.ts +150 -50
- package/src/cli/ai-setup/recommend.ts +118 -101
- package/src/cli/ask-seed.test.ts +68 -0
- package/src/cli/ask-seed.ts +79 -0
- package/src/cli/db-seed-redact.test.ts +25 -0
- package/src/cli/db-seed.ts +36 -7
- package/src/cli/db.test.ts +1 -1
- package/src/cli/db.ts +2 -2
- package/src/cli/dev-app-runner.ts +6 -0
- package/src/cli/dev-controls.test.ts +107 -0
- package/src/cli/dev-controls.ts +354 -0
- package/src/cli/dev-schema-sync.ts +12 -4
- package/src/cli/dev.test.ts +50 -30
- package/src/cli/dev.ts +633 -66
- package/src/cli/docker-cli.test.ts +15 -0
- package/src/cli/drizzle-env.test.ts +40 -6
- package/src/cli/drizzle-env.ts +22 -1
- package/src/cli/ensure-drizzle-config.ts +6 -4
- package/src/cli/hero-meta.test.ts +13 -1
- package/src/cli/hero-meta.ts +28 -6
- package/src/cli/index.ts +5 -0
- package/src/cli/registry.ts +24 -0
- package/src/cli/replay.test.ts +105 -0
- package/src/cli/replay.ts +206 -0
- package/src/cli/start.ts +12 -1
- package/src/compiler/effects-infer.ts +37 -8
- package/src/compiler/extract.test.ts +125 -0
- package/src/compiler/extract.ts +31 -6
- package/src/config/index.ts +3 -3
- package/src/console/server/claim.ts +3 -1
- package/src/console/ui/dist/assets/{index-ClX0ZCe5.js → index-D-9KFce5.js} +2 -2
- package/src/console/ui/dist/assets/panel-overview-CW0CQEc2.js +1 -0
- package/src/console/ui/dist/assets/panel-runs-BxuyDI3x.js +1 -0
- package/src/console/ui/dist/assets/{panel-signals-Dsxdu_AR.js → panel-signals-BXofbj7v.js} +1 -1
- package/src/console/ui/dist/assets/{panel-store-hy7O8HOs.js → panel-store-B8mH-arU.js} +1 -1
- package/src/console/ui/dist/assets/{panel-traces-CBiAe5go.js → panel-traces-DSA-ZWnf.js} +1 -1
- package/src/console/ui/dist/index.html +1 -1
- package/src/console/ui/overview/slo.ts +31 -3
- package/src/console/ui/overview/types.ts +6 -0
- package/src/console/ui/runs/errors.test.ts +53 -0
- package/src/console/ui/runs/errors.ts +88 -0
- package/src/console/ui/runs/index.ts +8 -0
- package/src/console/ui/runs/search.ts +16 -0
- package/src/console/ui/shell/panels/runs/RunsPanel.tsx +72 -1
- package/src/docker/ai-model-status.test.ts +101 -0
- package/src/docker/ai-model-status.ts +276 -0
- package/src/docker/compose-health.test.ts +115 -0
- package/src/docker/compose-health.ts +234 -0
- package/src/docker/compose.ts +24 -8
- package/src/docker/derive.ts +35 -9
- package/src/docker/docker.test.ts +304 -12
- package/src/docker/helpers.ts +56 -0
- package/src/docker/index.ts +52 -0
- package/src/docker/ollama-pull.ts +232 -0
- package/src/docker/recipes/cockroach.ts +24 -0
- package/src/docker/recipes/index.ts +32 -0
- package/src/docker/recipes/llama-cpp.ts +273 -0
- package/src/docker/recipes/ollama.ts +19 -23
- package/src/docker/recipes/pgdog.ts +4 -1
- package/src/docker/recipes/postgres.ts +5 -18
- package/src/docker/recipes/sglang.ts +55 -0
- package/src/docker/recipes/supabase.ts +21 -0
- package/src/docker/recipes/timescale.ts +18 -0
- package/src/docker/recipes/vllm.ts +44 -0
- package/src/docker/recipes/yugabyte.ts +23 -0
- package/src/docker/stack-id.test.ts +22 -0
- package/src/docker/stack-id.ts +38 -4
- package/src/docker/types.ts +15 -0
- package/src/drivers/ai-ollama.integration.test.ts +7 -4
- package/src/drivers/signal-engine.ts +10 -0
- package/src/drivers/signal-postgres.ts +24 -2
- package/src/drivers/signal-types.ts +10 -0
- package/src/elements/channel/fallback.test.ts +91 -0
- package/src/elements/channel/hard-bounce.test.ts +82 -0
- package/src/elements/channel/injection.test.ts +153 -0
- package/src/elements/channel/locale-ar.test.ts +102 -0
- package/src/elements/channel/otp-delivery.ts +5 -2
- package/src/elements/channel/receipts-ledger.test.ts +100 -0
- package/src/elements/channel/runtime.ts +24 -1
- package/src/elements/channel/test-helpers.ts +60 -0
- package/src/elements/channel/whatsapp-compliance.test.ts +124 -0
- package/src/elements/store/cache.test.ts +28 -0
- package/src/elements/store/cache.ts +1 -1
- package/src/elements/store/emit-drizzle.ts +43 -9
- package/src/elements/store/runtime.ts +1 -1
- package/src/elements/store/schema-decl.test.ts +2 -0
- package/src/elements/store/schema-decl.ts +15 -0
- package/src/elements/store/seed.ts +1 -1
- package/src/index.ts +1 -0
- package/src/kernel/app.ts +131 -10
- package/src/kernel/boot-bind/signal.ts +6 -2
- package/src/kernel/boot-bind/store.ts +2 -1
- package/src/kernel/boot.test.ts +6 -5
- package/src/kernel/boot.ts +113 -10
- package/src/kernel/compensate.test.ts +131 -0
- package/src/kernel/correlation.test.ts +108 -0
- package/src/kernel/effect-timing.test.ts +82 -0
- package/src/kernel/effects-stamping.test.ts +207 -0
- package/src/kernel/errors.ts +11 -0
- package/src/kernel/flow.ts +38 -0
- package/src/kernel/fx-runs.test.ts +49 -0
- package/src/kernel/fx.test.ts +14 -0
- package/src/kernel/fx.ts +153 -14
- package/src/manifest/sql-resource.ts +41 -0
- package/src/manifest/types.ts +8 -2
- package/src/runs/collect.ts +3 -0
- package/src/runs/export-otlp.ts +64 -0
- package/src/runs/index.ts +16 -0
- package/src/runs/parquet.ts +9 -0
- package/src/runs/types.ts +5 -0
- package/src/runs/window.test.ts +68 -0
- package/src/runs/window.ts +148 -0
- package/src/runtime/boot-warn.test.ts +45 -0
- package/src/runtime/boot-warn.ts +17 -0
- package/src/runtime/dev-request-log.ts +5 -5
- package/src/term.test.ts +95 -10
- package/src/term.ts +369 -14
- package/src/console/ui/dist/assets/panel-overview-DONDzBd2.js +0 -1
- 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
|
+
});
|
package/src/kernel/errors.ts
CHANGED
|
@@ -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.
|
package/src/kernel/flow.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/kernel/fx.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
},
|