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