okengine 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +105 -14
  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/plugins/otp.mdx +58 -46
  14. package/site/content/docs/providers/azure-redis.mdx +85 -0
  15. package/site/content/docs/providers/cockroachdb.mdx +86 -0
  16. package/site/content/docs/providers/digitalocean-caching.mdx +88 -0
  17. package/site/content/docs/providers/dragonfly-cloud.mdx +82 -0
  18. package/site/content/docs/providers/elasticache.mdx +92 -0
  19. package/site/content/docs/providers/index.mdx +101 -0
  20. package/site/content/docs/providers/memorystore.mdx +89 -0
  21. package/site/content/docs/providers/meta.json +20 -0
  22. package/site/content/docs/providers/neon.mdx +115 -0
  23. package/site/content/docs/providers/redis-cloud.mdx +93 -0
  24. package/site/content/docs/providers/supabase.mdx +117 -0
  25. package/site/content/docs/providers/upstash.mdx +91 -0
  26. package/site/content/docs/providers/yugabytedb.mdx +95 -0
  27. package/site/content/docs/recipes/caddy.mdx +128 -0
  28. package/site/content/docs/recipes/cockroachdb.mdx +127 -0
  29. package/site/content/docs/recipes/dragonfly.mdx +125 -0
  30. package/site/content/docs/recipes/index.mdx +146 -0
  31. package/site/content/docs/recipes/llama-cpp.mdx +153 -0
  32. package/site/content/docs/recipes/mailpit.mdx +136 -0
  33. package/site/content/docs/recipes/meilisearch.mdx +139 -0
  34. package/site/content/docs/recipes/meta.json +31 -0
  35. package/site/content/docs/recipes/ollama.mdx +142 -0
  36. package/site/content/docs/recipes/openbao.mdx +141 -0
  37. package/site/content/docs/recipes/pgdog.mdx +137 -0
  38. package/site/content/docs/recipes/postgres.mdx +136 -0
  39. package/site/content/docs/recipes/redis.mdx +130 -0
  40. package/site/content/docs/recipes/rustfs.mdx +148 -0
  41. package/site/content/docs/recipes/sglang.mdx +105 -0
  42. package/site/content/docs/recipes/supabase-docker.mdx +145 -0
  43. package/site/content/docs/recipes/timescale.mdx +129 -0
  44. package/site/content/docs/recipes/traefik.mdx +136 -0
  45. package/site/content/docs/recipes/valkey.mdx +121 -0
  46. package/site/content/docs/recipes/vllm.mdx +106 -0
  47. package/site/content/docs/recipes/yugabytedb.mdx +135 -0
  48. package/site/content/docs/reference/cli.md +42 -6
  49. package/site/content/docs/reference/configuration.mdx +5 -3
  50. package/site/content/docs/reference/environment-variables.mdx +12 -12
  51. package/site/content/docs/reference/fx.mdx +38 -4
  52. package/src/auth/otp-capability.ts +14 -14
  53. package/src/cli/ai-setup/ai-setup.test.ts +32 -12
  54. package/src/cli/ai-setup/apply.ts +65 -23
  55. package/src/cli/ai-setup/catalog.ts +1316 -35
  56. package/src/cli/ai-setup/detect-ollama.ts +47 -0
  57. package/src/cli/ai-setup/index.ts +44 -7
  58. package/src/cli/ai-setup/prompts.ts +430 -486
  59. package/src/cli/ai-setup/recommend.test.ts +150 -50
  60. package/src/cli/ai-setup/recommend.ts +118 -101
  61. package/src/cli/ask-seed.test.ts +68 -0
  62. package/src/cli/ask-seed.ts +79 -0
  63. package/src/cli/db-seed-redact.test.ts +25 -0
  64. package/src/cli/db-seed.ts +36 -7
  65. package/src/cli/db.test.ts +1 -1
  66. package/src/cli/db.ts +2 -2
  67. package/src/cli/dev-controls.test.ts +107 -0
  68. package/src/cli/dev-controls.ts +354 -0
  69. package/src/cli/dev-schema-sync.ts +12 -4
  70. package/src/cli/dev.test.ts +50 -30
  71. package/src/cli/dev.ts +607 -66
  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/compiler/extract.ts +3 -3
  82. package/src/config/index.ts +3 -3
  83. package/src/console/server/claim.ts +3 -1
  84. package/src/console/ui/dist/assets/{index-ClX0ZCe5.js → index-D-9KFce5.js} +2 -2
  85. package/src/console/ui/dist/assets/panel-overview-CW0CQEc2.js +1 -0
  86. package/src/console/ui/dist/assets/panel-runs-BxuyDI3x.js +1 -0
  87. package/src/console/ui/dist/assets/{panel-signals-Dsxdu_AR.js → panel-signals-BXofbj7v.js} +1 -1
  88. package/src/console/ui/dist/assets/{panel-store-hy7O8HOs.js → panel-store-B8mH-arU.js} +1 -1
  89. package/src/console/ui/dist/assets/{panel-traces-CBiAe5go.js → panel-traces-DSA-ZWnf.js} +1 -1
  90. package/src/console/ui/dist/index.html +1 -1
  91. package/src/console/ui/overview/slo.ts +31 -3
  92. package/src/console/ui/overview/types.ts +6 -0
  93. package/src/console/ui/runs/errors.test.ts +53 -0
  94. package/src/console/ui/runs/errors.ts +88 -0
  95. package/src/console/ui/runs/index.ts +8 -0
  96. package/src/console/ui/runs/search.ts +16 -0
  97. package/src/console/ui/shell/panels/runs/RunsPanel.tsx +72 -1
  98. package/src/docker/ai-model-status.test.ts +101 -0
  99. package/src/docker/ai-model-status.ts +276 -0
  100. package/src/docker/compose-health.test.ts +115 -0
  101. package/src/docker/compose-health.ts +234 -0
  102. package/src/docker/compose.ts +24 -8
  103. package/src/docker/derive.ts +35 -9
  104. package/src/docker/docker.test.ts +304 -12
  105. package/src/docker/helpers.ts +56 -0
  106. package/src/docker/index.ts +52 -0
  107. package/src/docker/ollama-pull.ts +232 -0
  108. package/src/docker/recipes/cockroach.ts +24 -0
  109. package/src/docker/recipes/index.ts +32 -0
  110. package/src/docker/recipes/llama-cpp.ts +273 -0
  111. package/src/docker/recipes/ollama.ts +19 -23
  112. package/src/docker/recipes/pgdog.ts +4 -1
  113. package/src/docker/recipes/postgres.ts +5 -18
  114. package/src/docker/recipes/sglang.ts +55 -0
  115. package/src/docker/recipes/supabase.ts +21 -0
  116. package/src/docker/recipes/timescale.ts +18 -0
  117. package/src/docker/recipes/vllm.ts +44 -0
  118. package/src/docker/recipes/yugabyte.ts +23 -0
  119. package/src/docker/stack-id.test.ts +22 -0
  120. package/src/docker/stack-id.ts +38 -4
  121. package/src/docker/types.ts +15 -0
  122. package/src/drivers/ai-ollama.integration.test.ts +7 -4
  123. package/src/drivers/signal-engine.ts +10 -0
  124. package/src/drivers/signal-postgres.ts +24 -2
  125. package/src/drivers/signal-types.ts +10 -0
  126. package/src/elements/channel/fallback.test.ts +91 -0
  127. package/src/elements/channel/hard-bounce.test.ts +82 -0
  128. package/src/elements/channel/injection.test.ts +153 -0
  129. package/src/elements/channel/locale-ar.test.ts +102 -0
  130. package/src/elements/channel/otp-delivery.ts +5 -2
  131. package/src/elements/channel/receipts-ledger.test.ts +100 -0
  132. package/src/elements/channel/runtime.ts +24 -1
  133. package/src/elements/channel/test-helpers.ts +60 -0
  134. package/src/elements/channel/whatsapp-compliance.test.ts +124 -0
  135. package/src/elements/store/cache.ts +1 -1
  136. package/src/elements/store/emit-drizzle.ts +43 -9
  137. package/src/elements/store/runtime.ts +1 -1
  138. package/src/elements/store/schema-decl.test.ts +2 -0
  139. package/src/elements/store/schema-decl.ts +15 -0
  140. package/src/elements/store/seed.ts +1 -1
  141. package/src/index.ts +1 -0
  142. package/src/kernel/app.ts +118 -11
  143. package/src/kernel/boot-bind/signal.ts +6 -2
  144. package/src/kernel/boot-bind/store.ts +2 -1
  145. package/src/kernel/boot.test.ts +6 -5
  146. package/src/kernel/boot.ts +8 -3
  147. package/src/kernel/compensate.test.ts +131 -0
  148. package/src/kernel/correlation.test.ts +108 -0
  149. package/src/kernel/effect-timing.test.ts +82 -0
  150. package/src/kernel/flow.ts +38 -0
  151. package/src/kernel/fx-runs.test.ts +49 -0
  152. package/src/kernel/fx.test.ts +14 -0
  153. package/src/kernel/fx.ts +111 -1
  154. package/src/manifest/types.ts +8 -2
  155. package/src/plugins/auth-delivery.mailpit.integration.test.ts +1 -1
  156. package/src/plugins/auth-methods.security.test.ts +3 -3
  157. package/src/plugins/auth-methods.test.ts +1 -1
  158. package/src/plugins/index.ts +2 -2
  159. package/src/plugins/otp.test.ts +21 -19
  160. package/src/plugins/otp.ts +36 -34
  161. package/src/plugins/taqnyat.live.test.ts +3 -3
  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
@@ -7,7 +7,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
7
7
  import { tmpdir } from "node:os";
8
8
  import { join } from "node:path";
9
9
  import type { OkeConfig } from "../config/index.ts";
10
- import { resolveDrizzleKitEnv } from "./drizzle-env.ts";
10
+ import { applyComposeEnvToProcess, resolveDrizzleKitEnv } from "./drizzle-env.ts";
11
11
 
12
12
  const dirs: string[] = [];
13
13
 
@@ -46,11 +46,18 @@ describe("resolveDrizzleKitEnv", () => {
46
46
  join(dir, "docker", ".env.docker"),
47
47
  "DATABASE_URL=postgres://u:p@127.0.0.1:5432/oke\n",
48
48
  );
49
- const ctx = await resolveDrizzleKitEnv(dir, dualConfig, "docker");
50
- expect(ctx.dialect).toBe("postgresql");
51
- expect(ctx.overlay.OKE_DRIZZLE_DIALECT).toBe("postgresql");
52
- expect(ctx.overlay.DATABASE_URL).toBe("postgres://u:p@127.0.0.1:5432/oke");
53
- expect(ctx.overlay.OKE_SQLITE_URL).toBeUndefined();
49
+ const prevDb = process.env["DATABASE_URL"];
50
+ delete process.env["DATABASE_URL"];
51
+ try {
52
+ const ctx = await resolveDrizzleKitEnv(dir, dualConfig, "docker");
53
+ expect(ctx.dialect).toBe("postgresql");
54
+ expect(ctx.overlay.OKE_DRIZZLE_DIALECT).toBe("postgresql");
55
+ expect(ctx.overlay.DATABASE_URL).toBe("postgres://u:p@127.0.0.1:5432/oke");
56
+ expect(ctx.overlay.OKE_SQLITE_URL).toBeUndefined();
57
+ } finally {
58
+ if (prevDb === undefined) delete process.env["DATABASE_URL"];
59
+ else process.env["DATABASE_URL"] = prevDb;
60
+ }
54
61
  });
55
62
 
56
63
  test("docker dialect is postgresql even when DATABASE_URL unset (no URL guessing)", async () => {
@@ -65,3 +72,30 @@ describe("resolveDrizzleKitEnv", () => {
65
72
  await expect(resolveDrizzleKitEnv(dir, bad, "local")).rejects.toThrow(/not supported/);
66
73
  });
67
74
  });
75
+
76
+ describe("applyComposeEnvToProcess", () => {
77
+ test("fills unset keys from docker/.env.docker without overwriting", async () => {
78
+ const dir = await tempDir();
79
+ await mkdir(join(dir, "docker"), { recursive: true });
80
+ await writeFile(
81
+ join(dir, "docker", ".env.docker"),
82
+ "DATABASE_URL=postgres://u:p@127.0.0.1:5432/oke\nREDIS_URL=redis://127.0.0.1:6379\n",
83
+ );
84
+ const prevDb = process.env["DATABASE_URL"];
85
+ const prevRedis = process.env["REDIS_URL"];
86
+ delete process.env["DATABASE_URL"];
87
+ process.env["REDIS_URL"] = "redis://already-set";
88
+ try {
89
+ const applied = await applyComposeEnvToProcess(dir);
90
+ expect(applied).toContain("DATABASE_URL");
91
+ expect(applied).not.toContain("REDIS_URL");
92
+ expect(process.env["DATABASE_URL"]).toBe("postgres://u:p@127.0.0.1:5432/oke");
93
+ expect(process.env["REDIS_URL"]).toBe("redis://already-set");
94
+ } finally {
95
+ if (prevDb === undefined) delete process.env["DATABASE_URL"];
96
+ else process.env["DATABASE_URL"] = prevDb;
97
+ if (prevRedis === undefined) delete process.env["REDIS_URL"];
98
+ else process.env["REDIS_URL"] = prevRedis;
99
+ }
100
+ });
101
+ });
@@ -26,7 +26,7 @@ export interface DrizzleKitEnvContext {
26
26
  *
27
27
  * @param cwd - Project root
28
28
  */
29
- async function readComposeEnv(cwd: string): Promise<Map<string, string>> {
29
+ export async function readComposeEnv(cwd: string): Promise<Map<string, string>> {
30
30
  const { path } = resolveComposeEnvPath(cwd);
31
31
  const file = Bun.file(path);
32
32
  if (!(await file.exists())) return new Map();
@@ -37,6 +37,27 @@ async function readComposeEnv(cwd: string): Promise<Map<string, string>> {
37
37
  }
38
38
  }
39
39
 
40
+ /**
41
+ * Fill unset `process.env` keys from compose `.env.docker` (never overwrites).
42
+ *
43
+ * SQL / Redis binders read `process.env` directly — Vault can resolve the
44
+ * same file, but `oke db seed` must hydrate env before `app.boot()`.
45
+ *
46
+ * @param cwd - Project root
47
+ * @returns Keys that were applied
48
+ */
49
+ export async function applyComposeEnvToProcess(cwd: string): Promise<readonly string[]> {
50
+ const compose = await readComposeEnv(cwd);
51
+ const applied: string[] = [];
52
+ for (const [key, value] of compose) {
53
+ if (process.env[key] === undefined) {
54
+ process.env[key] = value;
55
+ applied.push(key);
56
+ }
57
+ }
58
+ return applied;
59
+ }
60
+
40
61
  /**
41
62
  * Resolve the drizzle-kit env context for a project + env.
42
63
  *
@@ -13,17 +13,19 @@ const DRIZZLE_CONFIG_TEMPLATE = `import { defineConfig } from "drizzle-kit";
13
13
  *
14
14
  * Dialect comes from OKE's \`store.sql\` driver map (resolved by the CLI and
15
15
  * injected as \`OKE_DRIZZLE_DIALECT\`) — never inferred from \`DATABASE_URL\`
16
- * presence. \`src/schema.generated.ts\` is emitted from \`src/schema.decl.ts\`
16
+ * presence. \`src/db/schema.generated.ts\` is emitted from \`src/db/schema.decl.ts\`
17
17
  * for the active dialect as a pre-step of \`oke db\` / \`oke dev\`.
18
18
  *
19
- * Versioned prod migrations land in \`./drizzle\` — never mix with \`.oke/\`.
19
+ * Versioned prod migrations land in \`./src/db/migrations\` — never mix with \`.oke/\`.
20
20
  */
21
21
  const dialect = (process.env.OKE_DRIZZLE_DIALECT ?? "sqlite") as "sqlite" | "postgresql";
22
22
 
23
23
  export default defineConfig({
24
24
  dialect,
25
- schema: "./src/schema.generated.ts",
26
- out: "./drizzle",
25
+ schema: "./src/db/schema.generated.ts",
26
+ out: "./src/db/migrations",
27
+ // Core runtime tables (oke_crons, …) are created by drivers — not domain schema.
28
+ tablesFilter: ["!oke_*"],
27
29
  dbCredentials: {
28
30
  url:
29
31
  dialect === "postgresql"
@@ -49,7 +49,8 @@ describe("hero-meta", () => {
49
49
  "ai",
50
50
  ]);
51
51
  expect(rows.find((r) => r.element === "store")?.detail).toContain("sqlite");
52
- expect(rows.find((r) => r.element === "flow")?.detail).toBe("");
52
+ expect(rows.find((r) => r.element === "flow")?.detail).toBe("");
53
+ expect(rows.find((r) => r.element === "flow")?.status).toBe("ready");
53
54
  });
54
55
 
55
56
  test("docker profile applies to every element", () => {
@@ -81,4 +82,15 @@ describe("hero-meta", () => {
81
82
  expect(snap.runtimeEnv).toBe("local");
82
83
  expect(decodeHeroSnapshot(encodeHeroSnapshot(snap))).toEqual(snap);
83
84
  });
85
+
86
+ test("hero AI row includes configured model id", () => {
87
+ const snap = buildDevHeroSnapshot({
88
+ config: SAMPLE,
89
+ docker: true,
90
+ aiModel: "gemma4:e4b-q4_K_M",
91
+ nodeEnv: "development",
92
+ });
93
+ const ai = snap.elements.find((r) => r.element === "ai")?.detail ?? "";
94
+ expect(ai).toContain("gemma4:e4b-q4_K_M");
95
+ });
84
96
  });
@@ -9,6 +9,7 @@ import {
9
9
  type DriversConfig,
10
10
  type OkeConfig,
11
11
  } from "../config/index.ts";
12
+ import type { DevStatus } from "../term.ts";
12
13
 
13
14
  /** How the process is running (local laptop → production). */
14
15
  export type DevRuntimeProfile = "local" | "docker" | "test" | "production";
@@ -21,6 +22,8 @@ export type HeroElementRow = {
21
22
  readonly element: string;
22
23
  /** Driver summary, or `—` when unbound. */
23
24
  readonly detail: string;
25
+ /** Status dot — ready / pending / error / idle. */
26
+ readonly status?: DevStatus;
24
27
  };
25
28
 
26
29
  /** Serializable hero payload (TTY + soft-reload env). */
@@ -148,6 +151,10 @@ export function resolveHeroElements(
148
151
  readonly docker: boolean;
149
152
  readonly sqlDriver?: string;
150
153
  readonly kvDriver?: string;
154
+ /** Configured model id (`OKE_AI_MODEL`) when known. */
155
+ readonly aiModel?: string;
156
+ /** AI readiness when known (model loading / ready). */
157
+ readonly aiStatus?: DevStatus;
151
158
  /** Which driver map column to read (default `local`). */
152
159
  readonly configEnv?: ConfigEnv;
153
160
  },
@@ -162,8 +169,13 @@ export function resolveHeroElements(
162
169
  resolveDriverId(drivers?.store?.kv, configEnv) ??
163
170
  (options.docker ? "redis" : "memory");
164
171
 
172
+ const aiDriver = resolveDriverId(drivers?.ai, configEnv);
173
+ const aiModel = options.aiModel?.trim();
174
+ const aiDetail = aiDriver && aiModel ? `${aiDriver} · ${aiModel}` : (aiDriver ?? "—");
175
+
165
176
  const byName: Record<string, string> = {
166
- flow: "●",
177
+ // Status ● is rendered separately — flow has no driver id.
178
+ flow: "",
167
179
  signal: resolveDriverId(drivers?.signal, configEnv) ?? "—",
168
180
  store: storeDetail(drivers?.store, configEnv, {
169
181
  sql: options.sqlDriver,
@@ -173,13 +185,17 @@ export function resolveHeroElements(
173
185
  gate: gateKv,
174
186
  vault: resolveDriverId(drivers?.vault, configEnv) ?? "—",
175
187
  channel: channelDetail(drivers?.channel, configEnv),
176
- ai: resolveDriverId(drivers?.ai, configEnv) ?? "—",
188
+ ai: aiDetail,
177
189
  };
178
190
 
179
- return EIGHT_ELEMENTS.map((element) => ({
180
- element,
181
- detail: byName[element] ?? "—",
182
- }));
191
+ return EIGHT_ELEMENTS.map((element) => {
192
+ const detail = byName[element] ?? "—";
193
+ const idle = detail === "—" || detail === "";
194
+ let status: DevStatus = idle && element !== "flow" ? "idle" : "ready";
195
+ if (element === "flow") status = "ready";
196
+ if (element === "ai" && options.aiStatus && !idle) status = options.aiStatus;
197
+ return { element, detail, status };
198
+ });
183
199
  }
184
200
 
185
201
  /**
@@ -192,6 +208,10 @@ export function buildDevHeroSnapshot(options: {
192
208
  readonly docker: boolean;
193
209
  readonly sqlDriver?: string;
194
210
  readonly kvDriver?: string;
211
+ /** Configured model id (`OKE_AI_MODEL`) when known. */
212
+ readonly aiModel?: string;
213
+ /** AI readiness when known (model loading / ready). */
214
+ readonly aiStatus?: DevStatus;
195
215
  readonly version?: string;
196
216
  readonly nodeEnv?: string;
197
217
  }): DevHeroSnapshot {
@@ -207,6 +227,8 @@ export function buildDevHeroSnapshot(options: {
207
227
  docker: options.docker,
208
228
  sqlDriver: options.sqlDriver,
209
229
  kvDriver: options.kvDriver,
230
+ aiModel: options.aiModel,
231
+ aiStatus: options.aiStatus,
210
232
  }),
211
233
  ...(options.version !== undefined ? { version: options.version } : {}),
212
234
  };
package/src/cli/index.ts CHANGED
@@ -19,6 +19,7 @@ import { imagesCli } from "./images.ts";
19
19
  import { modeCli } from "./mode.ts";
20
20
  import { privacyEraseCli } from "./privacy-erase.ts";
21
21
  import { formatOkeHelp } from "./registry.ts";
22
+ import { replayCli } from "./replay.ts";
22
23
  import { schemaCli } from "./schema.ts";
23
24
  import { stackCli } from "./stack.ts";
24
25
  import { startCli } from "./start.ts";
@@ -88,6 +89,10 @@ if (cmd === "branch") {
88
89
  process.exit(await branchCli(sub ? [sub, ...rest] : rest));
89
90
  }
90
91
 
92
+ if (cmd === "replay") {
93
+ process.exit(await replayCli(sub ? [sub, ...rest] : rest));
94
+ }
95
+
91
96
  if (cmd === "privacy" && sub === "erase") {
92
97
  process.exit(await privacyEraseCli(rest));
93
98
  }
@@ -466,6 +466,30 @@ export const OKE_COMMANDS: readonly CliCommand[] = [
466
466
  HELP,
467
467
  ],
468
468
  },
469
+ {
470
+ name: "replay",
471
+ summary: "re-invoke a past Flow from a Runs WideEvent",
472
+ leaf: true,
473
+ flags: [
474
+ {
475
+ long: "--request-id",
476
+ short: "-r",
477
+ takesValue: true,
478
+ valueName: "id",
479
+ summary: "WideEvent / run id to replay",
480
+ },
481
+ ENTRY,
482
+ {
483
+ long: "--dry-run",
484
+ summary: "Stub irreversible effects (send / ask)",
485
+ },
486
+ {
487
+ long: "--live",
488
+ summary: "Allow live re-execution of send / ask effects",
489
+ },
490
+ HELP,
491
+ ],
492
+ },
469
493
  {
470
494
  name: "privacy",
471
495
  summary: "crypto-shred subject data",
@@ -0,0 +1,105 @@
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 { eventHasIrreversible, findRunById, runReplay } from "./replay.ts";
5
+
6
+ function sampleEvent(overrides: Partial<WideEvent> = {}): WideEvent {
7
+ return {
8
+ id: "run-1",
9
+ flow: "notes.create",
10
+ trigger: "http",
11
+ plane: "user",
12
+ gates: [],
13
+ cache: "none",
14
+ error: null,
15
+ input: { title: "hello" },
16
+ effects: [],
17
+ logs: [],
18
+ durationMs: 12,
19
+ startedAt: 1,
20
+ endedAt: 13,
21
+ dimensions: {},
22
+ ...overrides,
23
+ };
24
+ }
25
+
26
+ describe("oke replay", () => {
27
+ test("eventHasIrreversible detects send/ask", () => {
28
+ expect(eventHasIrreversible(sampleEvent())).toBe(false);
29
+ expect(
30
+ eventHasIrreversible(
31
+ sampleEvent({
32
+ effects: [
33
+ {
34
+ kind: "send",
35
+ resource: "welcome",
36
+ timestamp: 1,
37
+ duration: 1,
38
+ reversibility: "irreversible",
39
+ },
40
+ ],
41
+ }),
42
+ ),
43
+ ).toBe(true);
44
+ });
45
+
46
+ test("findRunById + runReplay re-invokes with stored input", async () => {
47
+ const runs = createRunsRuntime({ driver: "memory" });
48
+ await runs.open();
49
+ const event = sampleEvent({ id: "req-42" });
50
+ await runs.append(event);
51
+
52
+ expect(await findRunById(runs, "req-42")).toEqual(event);
53
+
54
+ const lines: string[] = [];
55
+ let seenInput: unknown;
56
+ const code = await runReplay({
57
+ requestId: "req-42",
58
+ runs,
59
+ write: (t) => lines.push(t),
60
+ executeReplay: async (_entry, e, dryRun) => {
61
+ seenInput = e.input;
62
+ expect(dryRun).toBe(false);
63
+ return { output: { ok: true, title: (e.input as { title: string }).title } };
64
+ },
65
+ });
66
+ expect(code).toBe(0);
67
+ expect(seenInput).toEqual({ title: "hello" });
68
+ expect(lines.some((l) => l.includes("notes.create"))).toBe(true);
69
+
70
+ await runs.close();
71
+ });
72
+
73
+ test("defaults to dry-run when ledger has send", async () => {
74
+ const runs = createRunsRuntime({ driver: "memory" });
75
+ await runs.open();
76
+ await runs.append(
77
+ sampleEvent({
78
+ id: "req-send",
79
+ effects: [
80
+ {
81
+ kind: "send",
82
+ resource: "mail",
83
+ timestamp: 1,
84
+ duration: 2,
85
+ reversibility: "irreversible",
86
+ },
87
+ ],
88
+ }),
89
+ );
90
+
91
+ let dry: boolean | undefined;
92
+ const code = await runReplay({
93
+ requestId: "req-send",
94
+ runs,
95
+ write: () => {},
96
+ executeReplay: async (_e, _ev, dryRun) => {
97
+ dry = dryRun;
98
+ return { output: { ok: true } };
99
+ },
100
+ });
101
+ expect(code).toBe(0);
102
+ expect(dry).toBe(true);
103
+ await runs.close();
104
+ });
105
+ });
@@ -0,0 +1,206 @@
1
+ /**
2
+ * `oke replay --request-id <id>` — re-invoke a past Flow from a WideEvent.
3
+ */
4
+
5
+ import { resolve } from "node:path";
6
+ import { withDryRun } from "../kernel/dry-run.ts";
7
+ import type { AnyFlowDef } from "../kernel/flow.ts";
8
+ import type { InternalTrigger } from "../kernel/triggers.ts";
9
+ import { createRunsRuntime, type RunsRuntime, type WideEvent } from "../runs/index.ts";
10
+ import { resolveStartEntry } from "./start.ts";
11
+ import { flagValue, hasFlag } from "./args.ts";
12
+ import { EXIT_OK, EXIT_USAGE } from "./exit.ts";
13
+
14
+ /** Options for {@link runReplay}. */
15
+ export interface ReplayOptions {
16
+ readonly requestId: string;
17
+ readonly cwd?: string;
18
+ readonly entry?: string;
19
+ /** Force dry-run (stub irreversible effects). */
20
+ readonly dryRun?: boolean;
21
+ /** Force live replay even when the ledger has send/ask. */
22
+ readonly live?: boolean;
23
+ readonly write?: (text: string) => void;
24
+ /** Injected runs runtime (tests). */
25
+ readonly runs?: RunsRuntime;
26
+ /**
27
+ * Load and execute the app (tests).
28
+ *
29
+ * @param entry - Absolute entry path
30
+ * @param event - Wide event to replay
31
+ * @param dryRun - Whether to wrap in dry-run
32
+ */
33
+ readonly executeReplay?: (
34
+ entry: string,
35
+ event: WideEvent,
36
+ dryRun: boolean,
37
+ ) => Promise<{ readonly output: unknown; readonly failure?: unknown }>;
38
+ }
39
+
40
+ /**
41
+ * True when the event's effect ledger includes irreversible kinds.
42
+ *
43
+ * @param event - Wide event
44
+ */
45
+ export function eventHasIrreversible(event: WideEvent): boolean {
46
+ return event.effects.some((e) => e.kind === "send" || e.kind === "ask");
47
+ }
48
+
49
+ /**
50
+ * Look up a WideEvent by id across the runs store.
51
+ *
52
+ * @param runs - Open runs runtime
53
+ * @param id - Request / run id
54
+ */
55
+ export async function findRunById(runs: RunsRuntime, id: string): Promise<WideEvent | undefined> {
56
+ const all = await runs.all();
57
+ return all.find((e) => e.id === id);
58
+ }
59
+
60
+ /**
61
+ * Resolve an OkeApp export from an entry module.
62
+ *
63
+ * @param mod - Imported module
64
+ */
65
+ export function appFromModule(mod: Record<string, unknown>): {
66
+ execute: (
67
+ flow: AnyFlowDef,
68
+ input: unknown,
69
+ trigger: InternalTrigger,
70
+ ) => Promise<{ output: unknown; failure?: unknown }>;
71
+ bindings: readonly { flow: AnyFlowDef }[];
72
+ } | null {
73
+ const candidates = [mod.app, mod.default, mod.oke];
74
+ for (const c of candidates) {
75
+ if (
76
+ c &&
77
+ typeof c === "object" &&
78
+ "execute" in c &&
79
+ typeof (c as { execute: unknown }).execute === "function" &&
80
+ "bindings" in c
81
+ ) {
82
+ return c as {
83
+ execute: (
84
+ flow: AnyFlowDef,
85
+ input: unknown,
86
+ trigger: InternalTrigger,
87
+ ) => Promise<{ output: unknown; failure?: unknown }>;
88
+ bindings: readonly { flow: AnyFlowDef }[];
89
+ };
90
+ }
91
+ }
92
+ return null;
93
+ }
94
+
95
+ async function defaultExecuteReplay(
96
+ entry: string,
97
+ event: WideEvent,
98
+ dryRun: boolean,
99
+ ): Promise<{ readonly output: unknown; readonly failure?: unknown }> {
100
+ const mod = (await import(entry)) as Record<string, unknown>;
101
+ const app = appFromModule(mod);
102
+ if (!app) {
103
+ throw new Error(`oke replay: ${entry} must export \`app\` (oke({…}))`);
104
+ }
105
+ const flow = app.bindings.map((b) => b.flow).find((f) => f.name === event.flow);
106
+ if (!flow) {
107
+ throw new Error(`oke replay: flow "${event.flow}" not found in app bindings`);
108
+ }
109
+ if (event.input === undefined) {
110
+ throw new Error(
111
+ `oke replay: run "${event.id}" has no stored input — re-run after upgrading to a build that persists WideEvent.input`,
112
+ );
113
+ }
114
+ const trigger: InternalTrigger = { kind: "internal" };
115
+ const run = () => app.execute(flow, event.input, trigger);
116
+ if (dryRun) {
117
+ const wrapped = await withDryRun(run);
118
+ return wrapped.result as { output: unknown; failure?: unknown };
119
+ }
120
+ return run();
121
+ }
122
+
123
+ /**
124
+ * Replay a past run locally from the Runs store.
125
+ *
126
+ * @param options - Request id + entry
127
+ */
128
+ export async function runReplay(options: ReplayOptions): Promise<number> {
129
+ const write = options.write ?? ((t) => process.stdout.write(t));
130
+ const cwd = options.cwd ?? process.cwd();
131
+
132
+ try {
133
+ const runs =
134
+ options.runs ??
135
+ createRunsRuntime({
136
+ driver: "files",
137
+ localRoot: resolve(cwd, ".oke/runs"),
138
+ });
139
+ if (!runs.store) await runs.open();
140
+
141
+ const event = await findRunById(runs, options.requestId);
142
+ if (!event) {
143
+ console.error(`oke replay: run not found: ${options.requestId}`);
144
+ return 1;
145
+ }
146
+ if (event.input === undefined) {
147
+ console.error(
148
+ `oke replay: run "${options.requestId}" has no stored input (cannot re-invoke)`,
149
+ );
150
+ return 1;
151
+ }
152
+
153
+ const irreversible = eventHasIrreversible(event);
154
+ const dryRun = options.live === true ? false : options.dryRun === true || irreversible;
155
+ if (irreversible && !options.live && options.dryRun !== false) {
156
+ write(
157
+ "oke replay: ledger has send/ask — running as dry-run (pass --live to re-execute external effects)\n",
158
+ );
159
+ }
160
+
161
+ const execute = options.executeReplay ?? defaultExecuteReplay;
162
+ const entry = options.executeReplay
163
+ ? (options.entry ?? "test://entry")
164
+ : await resolveStartEntry(cwd, options.entry);
165
+ write(`oke replay: ${event.flow} ← ${event.id}${dryRun ? " (dry-run)" : ""}\n`);
166
+ const result = await execute(entry, event, dryRun);
167
+ if (result.failure) {
168
+ write(`oke replay: failed ${JSON.stringify(result.failure)}\n`);
169
+ return 1;
170
+ }
171
+ write(`oke replay: ok ${JSON.stringify(result.output ?? null)}\n`);
172
+ return EXIT_OK;
173
+ } catch (err) {
174
+ console.error(err instanceof Error ? err.message : String(err));
175
+ return 1;
176
+ }
177
+ }
178
+
179
+ /**
180
+ * CLI entry for `oke replay`.
181
+ *
182
+ * @param args - Args after `replay`
183
+ */
184
+ export async function replayCli(args: readonly string[]): Promise<number> {
185
+ if (hasFlag(args, "--help", "-h")) {
186
+ console.log(`oke replay --request-id <id> [--entry|-e src/app.ts] [--dry-run] [--live]
187
+
188
+ Re-invoke a past Flow locally from a WideEvent in the Runs store.
189
+ Defaults to dry-run when the ledger contains send/ask effects.
190
+ `);
191
+ return EXIT_OK;
192
+ }
193
+
194
+ const requestId = flagValue(args, "--request-id", "-r");
195
+ if (!requestId) {
196
+ console.error("Usage: oke replay --request-id <id>");
197
+ return EXIT_USAGE;
198
+ }
199
+
200
+ return runReplay({
201
+ requestId,
202
+ entry: flagValue(args, "--entry", "-e"),
203
+ dryRun: hasFlag(args, "--dry-run"),
204
+ live: hasFlag(args, "--live"),
205
+ });
206
+ }
@@ -22,6 +22,7 @@ import type {
22
22
  Journey,
23
23
  Manifest,
24
24
  RateStrategy,
25
+ ResourceRef,
25
26
  SecretContract,
26
27
  Signal,
27
28
  SignalDelivery,
@@ -1333,9 +1334,8 @@ function registerResourceMount(
1333
1334
  const resource = baseName ? scope.resources.get(baseName) : undefined;
1334
1335
 
1335
1336
  const unit = resource?.unit;
1336
- const storeRef = resource?.storeRef as Effects["reads"] extends readonly (infer R)[] | undefined
1337
- ? R
1338
- : never;
1337
+ const storeRef: ResourceRef | undefined =
1338
+ resource?.storeRef !== undefined ? (resource.storeRef as ResourceRef) : undefined;
1339
1339
  const effects: Effects | undefined = storeRef
1340
1340
  ? { reads: [storeRef], writes: [storeRef] }
1341
1341
  : undefined;
@@ -155,13 +155,13 @@ export interface DbConfig {
155
155
  /**
156
156
  * Abstract schema declare module (`store.schema.table` exports).
157
157
  * When present, `oke db` emits Drizzle into {@link generated} before sync.
158
- * Default `"src/schema.decl.ts"` (emit skipped if the file is absent).
158
+ * Default `"src/db/schema.decl.ts"` (legacy `"src/schema.decl.ts"` still resolved).
159
159
  */
160
160
  readonly declare?: string;
161
161
  /**
162
162
  * Generated Drizzle schema path written from abstract decls.
163
- * Default `"src/schema.generated.ts"`. Point `drizzle.config.ts` `schema`
164
- * at this file when using the abstract path.
163
+ * Default `"src/db/schema.generated.ts"` (legacy `"src/schema.generated.ts"`).
164
+ * Point `drizzle.config.ts` `schema` at this file when using the abstract path.
165
165
  */
166
166
  readonly generated?: string;
167
167
  /**
@@ -58,7 +58,9 @@ export function mintClaimCode(now: () => number = Date.now): ClaimCodeState {
58
58
  */
59
59
  export function printClaimCodeOnce(
60
60
  state: ClaimCodeState,
61
- write: (line: string) => void = (line) => console.log(line),
61
+ write: (line: string) => void = (line) => {
62
+ process.stdout.write(line);
63
+ },
62
64
  ): void {
63
65
  if (state.printed) return;
64
66
  state.printed = true;