okengine 0.6.1 → 0.8.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 (167) hide show
  1. package/AGENTS.md +2 -2
  2. package/README.md +73 -181
  3. package/package.json +3 -3
  4. package/site/content/docs/console/clock.mdx +25 -5
  5. package/site/content/docs/console/signals.mdx +13 -4
  6. package/site/content/docs/elements/ai.mdx +29 -2
  7. package/site/content/docs/elements/channel.mdx +42 -25
  8. package/site/content/docs/elements/clock.mdx +59 -25
  9. package/site/content/docs/elements/flow.mdx +8 -2
  10. package/site/content/docs/elements/gate.mdx +8 -1
  11. package/site/content/docs/elements/signal.mdx +173 -29
  12. package/site/content/docs/elements/store.mdx +595 -73
  13. package/site/content/docs/elements/vault.mdx +15 -5
  14. package/site/content/docs/get-started/basic-usage.mdx +13 -6
  15. package/site/content/docs/get-started/installation.mdx +52 -14
  16. package/site/content/docs/get-started/introduction.mdx +1 -1
  17. package/site/content/docs/plugins/magic-link.mdx +42 -0
  18. package/site/content/docs/plugins/phone-number.mdx +78 -17
  19. package/site/content/docs/plugins/two-factor.mdx +1 -0
  20. package/site/content/docs/reference/cli.md +32 -2
  21. package/site/content/docs/reference/configuration.mdx +15 -13
  22. package/site/content/docs/reference/environment-variables.mdx +32 -18
  23. package/site/content/docs/reference/errors.mdx +12 -11
  24. package/site/content/docs/reference/fx.mdx +15 -13
  25. package/site/content/docs/reference/security.md +3 -1
  26. package/src/cli/ai-setup/ai-setup.test.ts +144 -0
  27. package/src/cli/ai-setup/apply.ts +214 -0
  28. package/src/cli/ai-setup/catalog.ts +263 -0
  29. package/src/cli/ai-setup/detect-ollama.ts +166 -0
  30. package/src/cli/ai-setup/index.ts +228 -0
  31. package/src/cli/ai-setup/prompts.ts +649 -0
  32. package/src/cli/ai-setup/recommend.test.ts +100 -0
  33. package/src/cli/ai-setup/recommend.ts +203 -0
  34. package/src/cli/ai.ts +38 -0
  35. package/src/cli/db-auto-push.test.ts +6 -1
  36. package/src/cli/db-auto-push.ts +8 -3
  37. package/src/cli/db-seed.ts +359 -0
  38. package/src/cli/db.test.ts +341 -3
  39. package/src/cli/db.ts +75 -8
  40. package/src/cli/dev-db-push.test.ts +59 -0
  41. package/src/cli/dev.test.ts +40 -0
  42. package/src/cli/dev.ts +55 -1
  43. package/src/cli/docker-clean.test.ts +179 -0
  44. package/src/cli/docker-clean.ts +406 -0
  45. package/src/cli/docker.ts +16 -4
  46. package/src/cli/ensure-drizzle-config.ts +1 -1
  47. package/src/cli/hero-meta.test.ts +6 -5
  48. package/src/cli/hero-meta.ts +7 -1
  49. package/src/cli/index.ts +5 -0
  50. package/src/cli/load-config.images.test.ts +22 -0
  51. package/src/cli/load-config.ts +7 -2
  52. package/src/cli/openbao-bootstrap.test.ts +1 -0
  53. package/src/cli/openbao-bootstrap.ts +9 -2
  54. package/src/cli/registry.ts +116 -2
  55. package/src/client/live-gap.test.ts +35 -0
  56. package/src/client/transport.test.ts +21 -0
  57. package/src/client/transport.ts +27 -5
  58. package/src/compiler/effects-infer.ts +1 -0
  59. package/src/compiler/fixtures/skyport/oke.config.ts +1 -1
  60. package/src/config/define-config.test.ts +6 -6
  61. package/src/config/index.ts +5 -1
  62. package/src/console/server/console.test.ts +45 -0
  63. package/src/console/server/flows.ts +39 -9
  64. package/src/console/server/i18n.ts +41 -0
  65. package/src/console/server/vault.ts +12 -38
  66. package/src/console/ui/dist/assets/index-ClX0ZCe5.js +10 -0
  67. package/src/console/ui/dist/assets/{panel-access-BGv45snf.js → panel-access-ri1X9Otj.js} +1 -1
  68. package/src/console/ui/dist/assets/{panel-ai-B2S7LEii.js → panel-ai-DLHjRoJk.js} +1 -1
  69. package/src/console/ui/dist/assets/{panel-architecture-D7UJh91v.js → panel-architecture-DI3bQF3J.js} +1 -1
  70. package/src/console/ui/dist/assets/{panel-channels-9T3ybqRu.js → panel-channels-BDYWX29p.js} +1 -1
  71. package/src/console/ui/dist/assets/{panel-clock-Cb1UXGRQ.js → panel-clock-i-JpTjVB.js} +1 -1
  72. package/src/console/ui/dist/assets/{panel-diff-DmYbKWmN.js → panel-diff-CFuWxiXy.js} +1 -1
  73. package/src/console/ui/dist/assets/{panel-flows-PiHwT55z.js → panel-flows-B8TRhcoe.js} +1 -1
  74. package/src/console/ui/dist/assets/{panel-gates-BQGYXvjT.js → panel-gates-CfkBgco7.js} +1 -1
  75. package/src/console/ui/dist/assets/{panel-overview-BBnRO18l.js → panel-overview-DONDzBd2.js} +1 -1
  76. package/src/console/ui/dist/assets/{panel-plugins-D0PsmVw2.js → panel-plugins-DDJwTehL.js} +1 -1
  77. package/src/console/ui/dist/assets/{panel-runs-CWuRDe0r.js → panel-runs-D5zf-D9c.js} +1 -1
  78. package/src/console/ui/dist/assets/{panel-signals-Bbg4ewpP.js → panel-signals-Dsxdu_AR.js} +1 -1
  79. package/src/console/ui/dist/assets/{panel-store-CPCbsDRa.js → panel-store-hy7O8HOs.js} +1 -1
  80. package/src/console/ui/dist/assets/{panel-traces-DVAzuA_S.js → panel-traces-CBiAe5go.js} +1 -1
  81. package/src/console/ui/dist/assets/{panel-vault-D1_MvOmo.js → panel-vault-Za1GyJfM.js} +1 -1
  82. package/src/console/ui/dist/index.html +1 -1
  83. package/src/console/ui/shell/components/ui.tsx +5 -1
  84. package/src/console/ui/shell/setup/Wizard.tsx +26 -5
  85. package/src/docker/cleanup.test.ts +193 -0
  86. package/src/docker/cleanup.ts +355 -0
  87. package/src/docker/docker.test.ts +4 -0
  88. package/src/docker/index.ts +19 -0
  89. package/src/docker/recipes/ollama.ts +10 -5
  90. package/src/drivers/ai-ollama.ts +45 -2
  91. package/src/drivers/ai-providers.test.ts +31 -0
  92. package/src/drivers/channel-sently.test.ts +8 -0
  93. package/src/drivers/channel-taqnyat-mail.ts +34 -0
  94. package/src/drivers/channel-types.ts +71 -0
  95. package/src/drivers/clock-postgres.test.ts +258 -0
  96. package/src/drivers/clock-postgres.ts +410 -0
  97. package/src/drivers/index.ts +21 -0
  98. package/src/drivers/journal-postgres.test.ts +175 -0
  99. package/src/drivers/journal-postgres.ts +492 -0
  100. package/src/drivers/signal-engine.ts +98 -25
  101. package/src/drivers/signal-nats.ts +4 -4
  102. package/src/drivers/signal-postgres.ts +158 -64
  103. package/src/drivers/signal-redis.ts +4 -4
  104. package/src/drivers/signal-types.ts +61 -2
  105. package/src/drivers/vault-driver-removal.test.ts +5 -1
  106. package/src/elements/channel/runtime.ts +51 -0
  107. package/src/elements/channel.test.ts +71 -0
  108. package/src/elements/clock/chaos-child.ts +389 -0
  109. package/src/elements/clock/chaos.test.ts +204 -0
  110. package/src/elements/clock/durable.ts +7 -0
  111. package/src/elements/clock/reconcile.ts +110 -0
  112. package/src/elements/clock/runtime.ts +13 -0
  113. package/src/elements/clock.test.ts +164 -2
  114. package/src/elements/clock.ts +2 -1
  115. package/src/elements/gate/runtime.ts +6 -0
  116. package/src/elements/signal/chaos-child.ts +41 -4
  117. package/src/elements/signal/declare.ts +4 -1
  118. package/src/elements/signal/delivery-modes.test.ts +179 -0
  119. package/src/elements/signal/key-ordering.test.ts +306 -0
  120. package/src/elements/signal/lease-reclaim.test.ts +123 -0
  121. package/src/elements/signal/optional-emit.test.ts +76 -0
  122. package/src/elements/signal/order-lifecycle.test.ts +130 -0
  123. package/src/elements/signal/orphan-messages.test.ts +152 -0
  124. package/src/elements/signal/runtime.ts +11 -4
  125. package/src/elements/signal/schema-emit.test.ts +107 -0
  126. package/src/elements/signal.test.ts +89 -5
  127. package/src/elements/store/files-fx.ts +104 -0
  128. package/src/elements/store/files-image.test.ts +251 -0
  129. package/src/elements/store/files-image.ts +494 -0
  130. package/src/elements/store/runtime.ts +60 -0
  131. package/src/elements/store/seed.test.ts +27 -0
  132. package/src/elements/store/seed.ts +68 -0
  133. package/src/elements/store/sql-session.test.ts +39 -0
  134. package/src/elements/store/sql-session.ts +55 -0
  135. package/src/elements/store/upsert-app.test.ts +103 -0
  136. package/src/elements/store.ts +29 -0
  137. package/src/elements/vault/boot-chain.ts +150 -0
  138. package/src/elements/vault/runtime.ts +8 -0
  139. package/src/index.ts +15 -0
  140. package/src/kernel/app.ts +168 -15
  141. package/src/kernel/boot-bind/channel.test.ts +16 -0
  142. package/src/kernel/boot-bind/channel.ts +13 -0
  143. package/src/kernel/boot-bind/clock.ts +63 -7
  144. package/src/kernel/boot-bind/gate.ts +71 -3
  145. package/src/kernel/boot-bind/honor-config.test.ts +348 -0
  146. package/src/kernel/boot-bind/journal.ts +89 -0
  147. package/src/kernel/boot-bind/signal.ts +64 -7
  148. package/src/kernel/boot-bind/vault.ts +27 -8
  149. package/src/kernel/boot.test.ts +6 -4
  150. package/src/kernel/boot.ts +63 -15
  151. package/src/kernel/concurrency.ts +1 -1
  152. package/src/kernel/errors.ts +8 -2
  153. package/src/kernel/fx.test.ts +19 -0
  154. package/src/kernel/fx.ts +153 -9
  155. package/src/kernel/index.ts +6 -0
  156. package/src/kernel/journal-boot.test.ts +397 -0
  157. package/src/kernel/journal-suspend.ts +35 -0
  158. package/src/kernel/journal.test.ts +142 -0
  159. package/src/kernel/journal.ts +202 -27
  160. package/src/plugins/auth-methods.security.test.ts +10 -7
  161. package/src/plugins/phone-number.ts +67 -10
  162. package/src/plugins/taqnyat.live.test.ts +174 -0
  163. package/src/runtime/dev-request-log.test.ts +20 -1
  164. package/src/runtime/dev-request-log.ts +38 -0
  165. package/src/term.test.ts +15 -0
  166. package/src/term.ts +8 -3
  167. package/src/console/ui/dist/assets/index-CjxwRGVv.js +0 -10
@@ -10,6 +10,7 @@
10
10
  import { describe, expect, test } from "bun:test";
11
11
  import type { MailOptions, SendResult, Transport } from "sently";
12
12
  import { createChannelInbox, openConsoleChannel, type ChannelDriver } from "../drivers/index.ts";
13
+ import type { SmsOtpTransport, SmsTransport } from "../drivers/channel-types.ts";
13
14
  import { channel, createChannelRuntime, createConsentStore, FallbackTransport } from "./channel.ts";
14
15
 
15
16
  /** Create a sently-compatible transport that always fails. */
@@ -207,3 +208,73 @@ describe("console driver", () => {
207
208
  expect(inbox.entries[0]!.text).toBe("code 1234");
208
209
  });
209
210
  });
211
+
212
+ describe("provider-managed OTP (Taqnyat Verify)", () => {
213
+ function taqnyatSmsDriver(): ChannelDriver {
214
+ const otpTransport: SmsTransport & SmsOtpTransport = {
215
+ provider: "taqnyat-sms",
216
+ async send() {
217
+ return { messageId: "m", to: "", status: "sent", response: "" };
218
+ },
219
+ async sendOtp(opts) {
220
+ return {
221
+ requestId: opts.requestId,
222
+ to: opts.to,
223
+ code: 5,
224
+ response: "sent",
225
+ provider: "taqnyat-sms",
226
+ };
227
+ },
228
+ async verifyOtp(opts) {
229
+ return {
230
+ ok: true as const,
231
+ code: opts.code === "0000" ? 13 : 10,
232
+ message: "verified",
233
+ response: "ok",
234
+ provider: "taqnyat-sms",
235
+ };
236
+ },
237
+ };
238
+ return { id: "taqnyat", smsTransport: otpTransport };
239
+ }
240
+
241
+ test("sendOtp / verifyOtp dispatch to the taqnyat smsTransport", async () => {
242
+ const runtime = createChannelRuntime({ drivers: [taqnyatSmsDriver()] });
243
+ const sent = await runtime.sendOtp({ to: "+966500000000", requestId: "r1", lang: "en" });
244
+ expect(sent.code).toBe(5);
245
+ expect(sent.requestId).toBe("r1");
246
+ const verified = await runtime.verifyOtp({
247
+ to: "+966500000000",
248
+ requestId: "r1",
249
+ code: "6240",
250
+ });
251
+ expect(verified.ok).toBe(true);
252
+ expect(verified.code).toBe(10);
253
+ });
254
+
255
+ test("throws loudly when no SMS driver is bound", async () => {
256
+ const runtime = createChannelRuntime({});
257
+ await expect(runtime.sendOtp({ to: "+966500000000", requestId: "r1" })).rejects.toThrow(
258
+ "no SMS driver bound",
259
+ );
260
+ });
261
+
262
+ test("throws loudly naming the driver when SMS driver lacks OTP support", async () => {
263
+ const twilioLike: ChannelDriver = {
264
+ id: "msegat",
265
+ smsTransport: {
266
+ provider: "msegat",
267
+ async send() {
268
+ return { messageId: "m", to: "", status: "sent", response: "" };
269
+ },
270
+ },
271
+ };
272
+ const runtime = createChannelRuntime({ drivers: [twilioLike] });
273
+ await expect(runtime.sendOtp({ to: "+966500000000", requestId: "r1" })).rejects.toThrow(
274
+ /SMS driver "msegat" does not support provider-managed OTP/,
275
+ );
276
+ await expect(
277
+ runtime.verifyOtp({ to: "+966500000000", requestId: "r1", code: "1234" }),
278
+ ).rejects.toThrow(/does not support provider-managed OTP/);
279
+ });
280
+ });
@@ -0,0 +1,389 @@
1
+ /**
2
+ * Clock chaos child — multi-process leader election / durable resume.
3
+ *
4
+ * Args:
5
+ * tick-loop <storePath> <instanceId> <fireLogPath> <leaseMs> [every]
6
+ * hold-lease <storePath> <instanceId> <markerPath> <leaseMs>
7
+ * tick-loop-pg <databaseUrl> <instanceId> <fireLogPath> <leaseMs> [every]
8
+ * hold-lease-pg <databaseUrl> <instanceId> <markerPath> <leaseMs>
9
+ * durable-mid-step <journalPath> <markerPath> <runIdPath>
10
+ * journal-pg-start <databaseUrl> <instanceId> <stepLogPath> <markerPath> <leaseMs> <blockMs>
11
+ * journal-pg-resume <databaseUrl> <instanceId> <stepLogPath> <donePath> <leaseMs>
12
+ * journal-pg-park <databaseUrl> <instanceId> <stepLogPath> <parkedPath> <wakeMs>
13
+ * journal-pg-claim <databaseUrl> <instanceId> <stepLogPath> <leaseMs>
14
+ *
15
+ * Modes:
16
+ * tick-loop / tick-loop-pg — reconcile + tick until this instance fires once
17
+ * hold-lease / hold-lease-pg — acquire lease, write marker, hang (parent SIGKILLs)
18
+ * durable-mid-step — journal step 1, write marker + runId, hang
19
+ * journal-pg-start — boot oke() app, start durable run over HTTP, hang (parent SIGKILLs)
20
+ * journal-pg-resume — boot oke() app; orphan sweep resumes the dead run; exit when done
21
+ * journal-pg-park — boot oke() app, park a durable sleep over HTTP, exit
22
+ * journal-pg-claim — boot oke() app; race claimDueSleep; exit 0 on own execution
23
+ */
24
+
25
+ import { join } from "node:path";
26
+
27
+ import { createPostgresCronStore } from "../../drivers/clock-postgres.ts";
28
+ import { oke, type OkeApp } from "../../kernel/app.ts";
29
+ import { flow, type AnyFlowDef } from "../../kernel/flow.ts";
30
+ import { createFileJournalStore } from "../../kernel/journal.ts";
31
+ import { on } from "../../kernel/on.ts";
32
+ import { http } from "../../kernel/triggers.ts";
33
+ import { clock } from "./declare.ts";
34
+ import { runDurable } from "./durable.ts";
35
+ import { createFileCronStore, type CronStore } from "./reconcile.ts";
36
+ import { createClockRuntime } from "./runtime.ts";
37
+
38
+ const mode = process.argv[2];
39
+
40
+ if (!mode) {
41
+ console.error(
42
+ "usage: chaos-child <tick-loop|hold-lease|tick-loop-pg|hold-lease-pg|durable-mid-step> …",
43
+ );
44
+ process.exit(2);
45
+ }
46
+
47
+ const CRON_NAME = "chaos-job";
48
+
49
+ async function runTickLoop(
50
+ store: CronStore,
51
+ instanceId: string,
52
+ fireLogPath: string,
53
+ leaseMs: number,
54
+ every: string,
55
+ ) {
56
+ const rt = createClockRuntime({
57
+ instanceId,
58
+ store,
59
+ leaseMs: Number.isFinite(leaseMs) ? leaseMs : 100,
60
+ });
61
+ rt.register(clock(CRON_NAME, { every }));
62
+ await rt.reconcile();
63
+ rt.onCron(CRON_NAME, async () => {
64
+ const line = `${JSON.stringify({ instanceId, firedAt: Date.now() })}\n`;
65
+ const prev = (await Bun.file(fireLogPath).exists()) ? await Bun.file(fireLogPath).text() : "";
66
+ await Bun.write(fireLogPath, prev + line);
67
+ });
68
+
69
+ const deadline = Date.now() + 15_000;
70
+ while (Date.now() < deadline) {
71
+ const { ran } = await rt.tick();
72
+ if (ran.includes(CRON_NAME)) process.exit(0);
73
+ await Bun.sleep(5);
74
+ }
75
+ console.error("tick-loop: timed out without firing");
76
+ process.exit(3);
77
+ }
78
+
79
+ async function runHoldLease(
80
+ store: CronStore,
81
+ instanceId: string,
82
+ markerPath: string,
83
+ leaseMs: number,
84
+ ) {
85
+ const rt = createClockRuntime({
86
+ instanceId,
87
+ store,
88
+ leaseMs: Number.isFinite(leaseMs) ? leaseMs : 100,
89
+ });
90
+ rt.register(clock(CRON_NAME, { every: "50ms" }));
91
+ await rt.reconcile();
92
+
93
+ rt.onCron(CRON_NAME, () => {});
94
+ const ok = await rt.runNow(CRON_NAME);
95
+ if (!ok) {
96
+ console.error("hold-lease: failed to acquire/fire");
97
+ process.exit(3);
98
+ }
99
+ const row = await store.get(CRON_NAME);
100
+ await Bun.write(
101
+ markerPath,
102
+ JSON.stringify({
103
+ instanceId,
104
+ heldAt: Date.now(),
105
+ leaderLeaseUntil: row?.leaderLeaseUntil,
106
+ }),
107
+ );
108
+ await Bun.sleep(60_000);
109
+ process.exit(0);
110
+ }
111
+
112
+ if (mode === "tick-loop") {
113
+ const storePath = process.argv[3];
114
+ const instanceId = process.argv[4];
115
+ const fireLogPath = process.argv[5];
116
+ const leaseMs = Number(process.argv[6] ?? 100);
117
+ const every = process.argv[7] ?? "1h";
118
+ if (!storePath || !instanceId || !fireLogPath) {
119
+ console.error(
120
+ "usage: chaos-child tick-loop <storePath> <instanceId> <fireLogPath> <leaseMs> [every]",
121
+ );
122
+ process.exit(2);
123
+ }
124
+ await runTickLoop(createFileCronStore(join(storePath)), instanceId, fireLogPath, leaseMs, every);
125
+ } else if (mode === "hold-lease") {
126
+ const storePath = process.argv[3];
127
+ const instanceId = process.argv[4];
128
+ const markerPath = process.argv[5];
129
+ const leaseMs = Number(process.argv[6] ?? 100);
130
+ if (!storePath || !instanceId || !markerPath) {
131
+ console.error("usage: chaos-child hold-lease <storePath> <instanceId> <markerPath> <leaseMs>");
132
+ process.exit(2);
133
+ }
134
+ await runHoldLease(createFileCronStore(join(storePath)), instanceId, markerPath, leaseMs);
135
+ } else if (mode === "tick-loop-pg") {
136
+ const databaseUrl = process.argv[3];
137
+ const instanceId = process.argv[4];
138
+ const fireLogPath = process.argv[5];
139
+ const leaseMs = Number(process.argv[6] ?? 100);
140
+ const every = process.argv[7] ?? "1h";
141
+ if (!databaseUrl || !instanceId || !fireLogPath) {
142
+ console.error(
143
+ "usage: chaos-child tick-loop-pg <databaseUrl> <instanceId> <fireLogPath> <leaseMs> [every]",
144
+ );
145
+ process.exit(2);
146
+ }
147
+ const store = await createPostgresCronStore({ url: databaseUrl });
148
+ await runTickLoop(store, instanceId, fireLogPath, leaseMs, every);
149
+ } else if (mode === "hold-lease-pg") {
150
+ const databaseUrl = process.argv[3];
151
+ const instanceId = process.argv[4];
152
+ const markerPath = process.argv[5];
153
+ const leaseMs = Number(process.argv[6] ?? 100);
154
+ if (!databaseUrl || !instanceId || !markerPath) {
155
+ console.error(
156
+ "usage: chaos-child hold-lease-pg <databaseUrl> <instanceId> <markerPath> <leaseMs>",
157
+ );
158
+ process.exit(2);
159
+ }
160
+ const store = await createPostgresCronStore({ url: databaseUrl });
161
+ await runHoldLease(store, instanceId, markerPath, leaseMs);
162
+ } else if (mode === "durable-mid-step") {
163
+ const journalPath = process.argv[3];
164
+ const markerPath = process.argv[4];
165
+ const runIdPath = process.argv[5];
166
+ if (!journalPath || !markerPath || !runIdPath) {
167
+ console.error("usage: chaos-child durable-mid-step <journalPath> <markerPath> <runIdPath>");
168
+ process.exit(2);
169
+ }
170
+
171
+ const journalStore = createFileJournalStore(join(journalPath));
172
+ const charge = flow({
173
+ name: "chaos.charge",
174
+ durable: true,
175
+ do: async (_input, fx) => {
176
+ const intent = await fx.step("create-intent", async () => {
177
+ const runs = await journalStore.list();
178
+ const run = runs[0];
179
+ if (run) await Bun.write(runIdPath, run.id);
180
+ await Bun.write(markerPath, "step1");
181
+ return { id: "pi_chaos" };
182
+ });
183
+ await Bun.sleep(60_000);
184
+ return fx.step("confirm", () => intent.id === "pi_chaos");
185
+ },
186
+ });
187
+
188
+ await runDurable({
189
+ flow: charge,
190
+ input: { orderId: "o-chaos" },
191
+ journalStore,
192
+ });
193
+ process.exit(0);
194
+ } else if (mode === "journal-pg-start") {
195
+ const [databaseUrl, instanceId, stepLogPath, markerPath] = process.argv.slice(3);
196
+ const leaseMs = Number(process.argv[7] ?? 300);
197
+ const blockMs = Number(process.argv[8] ?? 60_000);
198
+ if (!databaseUrl || !instanceId || !stepLogPath || !markerPath) {
199
+ console.error(
200
+ "usage: chaos-child journal-pg-start <databaseUrl> <instanceId> <stepLogPath> <markerPath> <leaseMs> <blockMs>",
201
+ );
202
+ process.exit(2);
203
+ }
204
+ const app = await bootJournalPgApp({
205
+ url: databaseUrl!,
206
+ instanceId: instanceId!,
207
+ leaseMs,
208
+ flow: chargeFlow(instanceId!, stepLogPath!, blockMs),
209
+ });
210
+ void app.fetch(new Request("http://localhost/charge", { method: "POST", body: "{}" }));
211
+ // Marker once step 1 is journaled — then hang; the parent SIGKILLs mid-run.
212
+ const deadline = Date.now() + 15_000;
213
+ for (;;) {
214
+ const row = (await app.bootResult!.journal!.store.list())[0];
215
+ if (row && row.entries.length >= 1) {
216
+ await Bun.write(markerPath!, JSON.stringify({ runId: row.id, instanceId }));
217
+ break;
218
+ }
219
+ if (Date.now() > deadline) {
220
+ console.error("journal-pg-start: step 1 never journaled");
221
+ process.exit(3);
222
+ }
223
+ await Bun.sleep(10);
224
+ }
225
+ await Bun.sleep(3_600_000);
226
+ process.exit(0);
227
+ } else if (mode === "journal-pg-resume") {
228
+ const [databaseUrl, instanceId, stepLogPath, donePath] = process.argv.slice(3);
229
+ const leaseMs = Number(process.argv[7] ?? 300);
230
+ if (!databaseUrl || !instanceId || !stepLogPath || !donePath) {
231
+ console.error(
232
+ "usage: chaos-child journal-pg-resume <databaseUrl> <instanceId> <stepLogPath> <donePath> <leaseMs>",
233
+ );
234
+ process.exit(2);
235
+ }
236
+ // No fetch — the boot orphan scan / resume sweep must discover the dead run.
237
+ const app = await bootJournalPgApp({
238
+ url: databaseUrl!,
239
+ instanceId: instanceId!,
240
+ leaseMs,
241
+ flow: chargeFlow(instanceId!, stepLogPath!, 0),
242
+ });
243
+ const deadline = Date.now() + 20_000;
244
+ for (;;) {
245
+ await app.resumeDurable();
246
+ const row = (await app.bootResult!.journal!.store.list())[0];
247
+ if (row?.status === "completed") {
248
+ await Bun.write(donePath!, JSON.stringify({ runId: row.id, completedBy: instanceId }));
249
+ process.exit(0);
250
+ }
251
+ if (Date.now() > deadline) {
252
+ console.error("journal-pg-resume: orphan never resumed");
253
+ process.exit(3);
254
+ }
255
+ await Bun.sleep(25);
256
+ }
257
+ } else if (mode === "journal-pg-park") {
258
+ const [databaseUrl, instanceId, stepLogPath, parkedPath] = process.argv.slice(3);
259
+ const wakeMs = Number(process.argv[7] ?? 400);
260
+ if (!databaseUrl || !instanceId || !stepLogPath || !parkedPath) {
261
+ console.error(
262
+ "usage: chaos-child journal-pg-park <databaseUrl> <instanceId> <stepLogPath> <parkedPath> <wakeMs>",
263
+ );
264
+ process.exit(2);
265
+ }
266
+ const app = await bootJournalPgApp({
267
+ url: databaseUrl!,
268
+ instanceId: instanceId!,
269
+ leaseMs: 30_000,
270
+ flow: sleeperFlow(instanceId!, stepLogPath!, wakeMs),
271
+ });
272
+ const res = await app.fetch(
273
+ new Request("http://localhost/sleep", { method: "POST", body: "{}" }),
274
+ );
275
+ if (res.status !== 204) {
276
+ console.error(`journal-pg-park: expected 204 park, got ${res.status}`);
277
+ process.exit(3);
278
+ }
279
+ const row = (await app.bootResult!.journal!.store.list())[0];
280
+ await Bun.write(parkedPath!, JSON.stringify({ runId: row?.id, instanceId }));
281
+ process.exit(0);
282
+ } else if (mode === "journal-pg-claim") {
283
+ const [databaseUrl, instanceId, stepLogPath] = process.argv.slice(3);
284
+ const leaseMs = Number(process.argv[6] ?? 300);
285
+ if (!databaseUrl || !instanceId || !stepLogPath) {
286
+ console.error(
287
+ "usage: chaos-child journal-pg-claim <databaseUrl> <instanceId> <stepLogPath> <leaseMs>",
288
+ );
289
+ process.exit(2);
290
+ }
291
+ const app = await bootJournalPgApp({
292
+ url: databaseUrl!,
293
+ instanceId: instanceId!,
294
+ leaseMs,
295
+ flow: sleeperFlow(instanceId!, stepLogPath!, 400),
296
+ });
297
+ const deadline = Date.now() + 15_000;
298
+ for (;;) {
299
+ await app.resumeDurable();
300
+ if (await logHasStep(stepLogPath!, "confirm", instanceId!)) process.exit(0);
301
+ const row = (await app.bootResult!.journal!.store.list())[0];
302
+ if (row?.status === "completed") process.exit(3); // someone else claimed it
303
+ if (Date.now() > deadline) process.exit(3);
304
+ await Bun.sleep(25);
305
+ }
306
+ } else {
307
+ console.error(`unknown mode: ${mode}`);
308
+ process.exit(2);
309
+ }
310
+
311
+ /** Append one JSON line to the shared step log. */
312
+ async function appendStepLog(
313
+ path: string,
314
+ entry: { instanceId: string; step: string; at: number },
315
+ ): Promise<void> {
316
+ const prev = (await Bun.file(path).exists()) ? await Bun.file(path).text() : "";
317
+ await Bun.write(path, `${prev}${JSON.stringify(entry)}\n`);
318
+ }
319
+
320
+ /** True when the step log holds `step` executed by `instanceId`. */
321
+ async function logHasStep(path: string, step: string, instanceId: string): Promise<boolean> {
322
+ if (!(await Bun.file(path).exists())) return false;
323
+ const lines = (await Bun.file(path).text()).trim().split("\n").filter(Boolean);
324
+ return lines.some((l) => {
325
+ const e = JSON.parse(l) as { instanceId: string; step: string };
326
+ return e.step === step && e.instanceId === instanceId;
327
+ });
328
+ }
329
+
330
+ /** Durable charge flow: step 1 → block → step 2 (both logged). */
331
+ function chargeFlow(instanceId: string, stepLogPath: string, blockMs: number): AnyFlowDef {
332
+ return flow({
333
+ name: "chaos.journal.charge",
334
+ durable: true,
335
+ do: async (_input, fx) => {
336
+ const intent = await fx.step("create-intent", async () => {
337
+ await appendStepLog(stepLogPath, { instanceId, step: "create-intent", at: Date.now() });
338
+ return { id: "pi_chaos" };
339
+ });
340
+ if (blockMs > 0) await Bun.sleep(blockMs);
341
+ await fx.step("mid-flight", async () => {
342
+ await appendStepLog(stepLogPath, { instanceId, step: "mid-flight", at: Date.now() });
343
+ return intent.id === "pi_chaos";
344
+ });
345
+ return { ok: true };
346
+ },
347
+ });
348
+ }
349
+
350
+ /** Durable sleeper flow: step 1 → sleep → confirm (steps logged). */
351
+ function sleeperFlow(instanceId: string, stepLogPath: string, wakeMs: number): AnyFlowDef {
352
+ return flow({
353
+ name: "chaos.journal.sleeper",
354
+ durable: true,
355
+ do: async (_input, fx) => {
356
+ await fx.step("create-intent", async () => {
357
+ await appendStepLog(stepLogPath, { instanceId, step: "create-intent", at: Date.now() });
358
+ return { id: "pi_chaos" };
359
+ });
360
+ await fx.clock.sleep("verify-window", `${wakeMs}ms`);
361
+ await fx.step("confirm", async () => {
362
+ await appendStepLog(stepLogPath, { instanceId, step: "confirm", at: Date.now() });
363
+ return true;
364
+ });
365
+ return { ok: true };
366
+ },
367
+ });
368
+ }
369
+
370
+ /** Boot a real `oke()` app against live postgres (drivers.journal). */
371
+ async function bootJournalPgApp(options: {
372
+ readonly url: string;
373
+ readonly instanceId: string;
374
+ readonly leaseMs: number;
375
+ readonly flow: AnyFlowDef;
376
+ }): Promise<OkeApp> {
377
+ process.env.DATABASE_URL = options.url;
378
+ on(http.post("/charge"), options.flow);
379
+ on(http.post("/sleep"), options.flow);
380
+ const app = oke({
381
+ name: `chaos-journal-${options.instanceId}`,
382
+ env: "test",
383
+ startScheduler: false,
384
+ gate: { unguardedHttp: "allow" },
385
+ config: { drivers: { journal: { test: "postgres" } } },
386
+ });
387
+ await app.boot({ instanceId: options.instanceId, journalLeaseMs: options.leaseMs });
388
+ return app;
389
+ }
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Clock chaos — multi-process leader election + SIGKILL durable resume.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { mkdtemp, rm } from "node:fs/promises";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+
10
+ import { flow } from "../../kernel/flow.ts";
11
+ import { createFileJournalStore } from "../../kernel/journal.ts";
12
+ import { runDurable } from "./durable.ts";
13
+
14
+ const childPath = join(import.meta.dir, "chaos-child.ts");
15
+
16
+ async function waitForFile(path: string, timeoutMs = 10_000): Promise<boolean> {
17
+ const deadline = Date.now() + timeoutMs;
18
+ while (Date.now() < deadline) {
19
+ if (await Bun.file(path).exists()) return true;
20
+ await Bun.sleep(10);
21
+ }
22
+ return false;
23
+ }
24
+
25
+ describe("chaos — multi-process leader election", () => {
26
+ test("two OS processes sharing a file CronStore fire exactly once", async () => {
27
+ const dir = await mkdtemp(join(tmpdir(), "oke-clock-leader-"));
28
+ const storePath = join(dir, "crons.json");
29
+ const fireLogPath = join(dir, "fires.jsonl");
30
+ const leaseMs = 200;
31
+ try {
32
+ const a = Bun.spawn({
33
+ cmd: [
34
+ "bun",
35
+ childPath,
36
+ "tick-loop",
37
+ storePath,
38
+ "inst-a",
39
+ fireLogPath,
40
+ String(leaseMs),
41
+ "1h",
42
+ ],
43
+ stdout: "pipe",
44
+ stderr: "pipe",
45
+ });
46
+ const b = Bun.spawn({
47
+ cmd: [
48
+ "bun",
49
+ childPath,
50
+ "tick-loop",
51
+ storePath,
52
+ "inst-b",
53
+ fireLogPath,
54
+ String(leaseMs),
55
+ "1h",
56
+ ],
57
+ stdout: "pipe",
58
+ stderr: "pipe",
59
+ });
60
+
61
+ // Stop as soon as exactly one fire is observed; kill stragglers.
62
+ const deadline = Date.now() + 10_000;
63
+ while (Date.now() < deadline) {
64
+ if (await Bun.file(fireLogPath).exists()) {
65
+ const text = await Bun.file(fireLogPath).text();
66
+ if (text.trim().split("\n").filter(Boolean).length >= 1) break;
67
+ }
68
+ await Bun.sleep(10);
69
+ }
70
+ a.kill(9);
71
+ b.kill(9);
72
+ await Promise.all([a.exited, b.exited]);
73
+
74
+ expect(await Bun.file(fireLogPath).exists()).toBe(true);
75
+ const lines = (await Bun.file(fireLogPath).text()).trim().split("\n").filter(Boolean);
76
+ expect(lines).toHaveLength(1);
77
+ const fire = JSON.parse(lines[0]!) as { instanceId: string };
78
+ expect(["inst-a", "inst-b"]).toContain(fire.instanceId);
79
+ } finally {
80
+ await rm(dir, { recursive: true, force: true });
81
+ }
82
+ });
83
+
84
+ test("SIGKILL leader mid-lease: survivor takes over; report real latency", async () => {
85
+ const dir = await mkdtemp(join(tmpdir(), "oke-clock-takeover-"));
86
+ const storePath = join(dir, "crons.json");
87
+ const markerPath = join(dir, "held.json");
88
+ const fireLogPath = join(dir, "fires.jsonl");
89
+ const leaseMs = 120;
90
+ const tickSlackMs = 250;
91
+ try {
92
+ const leader = Bun.spawn({
93
+ cmd: ["bun", childPath, "hold-lease", storePath, "leader", markerPath, String(leaseMs)],
94
+ stdout: "pipe",
95
+ stderr: "pipe",
96
+ });
97
+
98
+ expect(await waitForFile(markerPath)).toBe(true);
99
+ const held = (await Bun.file(markerPath).json()) as {
100
+ leaderLeaseUntil?: number;
101
+ heldAt: number;
102
+ };
103
+
104
+ const killAt = Date.now();
105
+ leader.kill(9);
106
+ await leader.exited;
107
+
108
+ // every=50ms so the schedule is due again once the dead leader's lease expires.
109
+ const survivor = Bun.spawn({
110
+ cmd: [
111
+ "bun",
112
+ childPath,
113
+ "tick-loop",
114
+ storePath,
115
+ "survivor",
116
+ fireLogPath,
117
+ String(leaseMs),
118
+ "50ms",
119
+ ],
120
+ stdout: "pipe",
121
+ stderr: "pipe",
122
+ });
123
+ expect(await survivor.exited).toBe(0);
124
+
125
+ expect(await Bun.file(fireLogPath).exists()).toBe(true);
126
+ const lines = (await Bun.file(fireLogPath).text()).trim().split("\n").filter(Boolean);
127
+ expect(lines).toHaveLength(1);
128
+ const fire = JSON.parse(lines[0]!) as { instanceId: string; firedAt: number };
129
+ expect(fire.instanceId).toBe("survivor");
130
+
131
+ const takeoverMs = fire.firedAt - killAt;
132
+ // Physics: cannot take over before the leader's lease expires.
133
+ const leaseRemainingAtKill = Math.max(
134
+ 0,
135
+ (held.leaderLeaseUntil ?? killAt + leaseMs) - killAt,
136
+ );
137
+ expect(takeoverMs).toBeGreaterThanOrEqual(Math.max(0, leaseRemainingAtKill - 20));
138
+ // Upper bound: lease TTL + tick/poll slack (not a product SLA).
139
+ expect(takeoverMs).toBeLessThanOrEqual(leaseMs + tickSlackMs);
140
+
141
+ // Evidence for docs / later SLA choice — real measured latency.
142
+ console.log(
143
+ `[clock takeover] measured=${takeoverMs}ms leaseMs=${leaseMs} leaseRemainingAtKill=${leaseRemainingAtKill}ms`,
144
+ );
145
+ } finally {
146
+ await rm(dir, { recursive: true, force: true });
147
+ }
148
+ });
149
+ });
150
+
151
+ describe("chaos — SIGKILL durable mid-step", () => {
152
+ test("SIGKILL after step 1: resume skips completed step", async () => {
153
+ const dir = await mkdtemp(join(tmpdir(), "oke-clock-durable-kill-"));
154
+ const journalPath = join(dir, "journal.json");
155
+ const markerPath = join(dir, "step1");
156
+ const runIdPath = join(dir, "runId");
157
+ try {
158
+ const child = Bun.spawn({
159
+ cmd: ["bun", childPath, "durable-mid-step", journalPath, markerPath, runIdPath],
160
+ stdout: "pipe",
161
+ stderr: "pipe",
162
+ });
163
+
164
+ expect(await waitForFile(markerPath)).toBe(true);
165
+ expect(await waitForFile(runIdPath)).toBe(true);
166
+ // Give the journal a moment to flush step 1 after the marker write.
167
+ await Bun.sleep(50);
168
+ child.kill(9);
169
+ await child.exited;
170
+
171
+ const runId = (await Bun.file(runIdPath).text()).trim();
172
+ expect(runId.length).toBeGreaterThan(0);
173
+
174
+ const calls: string[] = [];
175
+ const journalStore = createFileJournalStore(journalPath);
176
+ const charge = flow({
177
+ name: "chaos.charge",
178
+ durable: true,
179
+ do: async (_input, fx) => {
180
+ const intent = await fx.step("create-intent", () => {
181
+ calls.push("create-intent");
182
+ return { id: "pi_chaos" };
183
+ });
184
+ return fx.step("confirm", () => {
185
+ calls.push("confirm");
186
+ return intent.id === "pi_chaos";
187
+ });
188
+ },
189
+ });
190
+
191
+ const resumed = await runDurable({
192
+ flow: charge,
193
+ input: { orderId: "o-chaos" },
194
+ journalStore,
195
+ runId,
196
+ });
197
+ expect(resumed.status).toBe("completed");
198
+ // create-intent must not re-run; confirm runs once.
199
+ expect(calls).toEqual(["confirm"]);
200
+ } finally {
201
+ await rm(dir, { recursive: true, force: true });
202
+ }
203
+ });
204
+ });
@@ -13,6 +13,7 @@ import {
13
13
  createJournal,
14
14
  isJournalSuspend,
15
15
  type Journal,
16
+ type JournalLeaseOptions,
16
17
  type JournalSession,
17
18
  type JournalStore,
18
19
  } from "../../kernel/journal.ts";
@@ -45,6 +46,11 @@ export interface RunDurableOptions {
45
46
  readonly journalStore: JournalStore;
46
47
  /** Resume an existing run id (crash recovery). */
47
48
  readonly runId?: string;
49
+ /**
50
+ * Run-level lease (SKIP LOCKED + lazy reclaim when the store supports it).
51
+ * Resume throws `JournalLeaseBusy` when another live instance holds the run.
52
+ */
53
+ readonly lease?: JournalLeaseOptions;
48
54
  /** Injectable clock. */
49
55
  readonly now?: () => number;
50
56
  /** Extra fx options (secrets, store runtime, …). */
@@ -63,6 +69,7 @@ export async function runDurable<O = unknown>(
63
69
  const journal: Journal = createJournal({
64
70
  store: options.journalStore,
65
71
  now,
72
+ ...(options.lease ? { lease: options.lease } : {}),
66
73
  });
67
74
 
68
75
  if (options.runId) {