okengine 0.6.0 → 0.7.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.
- package/AGENTS.md +2 -2
- package/README.md +98 -71
- package/package.json +4 -4
- package/site/content/docs/console/clock.mdx +25 -5
- package/site/content/docs/console/signals.mdx +13 -4
- package/site/content/docs/elements/ai.mdx +29 -2
- package/site/content/docs/elements/channel.mdx +90 -20
- package/site/content/docs/elements/clock.mdx +56 -24
- package/site/content/docs/elements/flow.mdx +2 -0
- package/site/content/docs/elements/gate.mdx +8 -1
- package/site/content/docs/elements/signal.mdx +173 -29
- package/site/content/docs/elements/store.mdx +464 -73
- package/site/content/docs/elements/vault.mdx +15 -5
- package/site/content/docs/get-started/basic-usage.mdx +13 -6
- package/site/content/docs/get-started/installation.mdx +49 -13
- package/site/content/docs/get-started/introduction.mdx +1 -1
- package/site/content/docs/reference/cli.md +30 -2
- package/site/content/docs/reference/configuration.mdx +18 -17
- package/site/content/docs/reference/environment-variables.mdx +44 -18
- package/site/content/docs/reference/errors.mdx +12 -11
- package/site/content/docs/reference/fx.mdx +15 -13
- package/site/content/docs/reference/security.md +3 -1
- package/src/cli/ai-setup/ai-setup.test.ts +144 -0
- package/src/cli/ai-setup/apply.ts +214 -0
- package/src/cli/ai-setup/catalog.ts +263 -0
- package/src/cli/ai-setup/detect-ollama.ts +166 -0
- package/src/cli/ai-setup/index.ts +228 -0
- package/src/cli/ai-setup/prompts.ts +649 -0
- package/src/cli/ai-setup/recommend.test.ts +100 -0
- package/src/cli/ai-setup/recommend.ts +203 -0
- package/src/cli/ai.ts +38 -0
- package/src/cli/db-auto-push.test.ts +6 -1
- package/src/cli/db-auto-push.ts +8 -3
- package/src/cli/dev-db-push.test.ts +59 -0
- package/src/cli/dev.test.ts +40 -0
- package/src/cli/dev.ts +55 -1
- package/src/cli/docker-clean.test.ts +179 -0
- package/src/cli/docker-clean.ts +406 -0
- package/src/cli/docker.ts +16 -4
- package/src/cli/ensure-drizzle-config.ts +1 -1
- package/src/cli/hero-meta.test.ts +6 -5
- package/src/cli/hero-meta.ts +7 -1
- package/src/cli/index.ts +5 -0
- package/src/cli/load-config.ts +4 -4
- package/src/cli/openbao-bootstrap.test.ts +1 -0
- package/src/cli/openbao-bootstrap.ts +9 -2
- package/src/cli/openbao-restart.integration.test.ts +106 -97
- package/src/cli/registry.ts +79 -1
- package/src/client/live-gap.test.ts +35 -0
- package/src/client/transport.test.ts +21 -0
- package/src/client/transport.ts +27 -5
- package/src/compiler/fixtures/skyport/oke.config.ts +1 -1
- package/src/config/define-config.test.ts +6 -6
- package/src/config/index.ts +1 -1
- package/src/console/server/console.test.ts +45 -0
- package/src/console/server/flows.ts +39 -9
- package/src/console/server/i18n.ts +41 -0
- package/src/console/server/vault.ts +12 -38
- package/src/console/ui/dist/assets/index-ClX0ZCe5.js +10 -0
- package/src/console/ui/dist/assets/{panel-access-BGv45snf.js → panel-access-ri1X9Otj.js} +1 -1
- package/src/console/ui/dist/assets/{panel-ai-B2S7LEii.js → panel-ai-DLHjRoJk.js} +1 -1
- package/src/console/ui/dist/assets/{panel-architecture-D7UJh91v.js → panel-architecture-DI3bQF3J.js} +1 -1
- package/src/console/ui/dist/assets/{panel-channels-9T3ybqRu.js → panel-channels-BDYWX29p.js} +1 -1
- package/src/console/ui/dist/assets/{panel-clock-Cb1UXGRQ.js → panel-clock-i-JpTjVB.js} +1 -1
- package/src/console/ui/dist/assets/{panel-diff-DmYbKWmN.js → panel-diff-CFuWxiXy.js} +1 -1
- package/src/console/ui/dist/assets/{panel-flows-PiHwT55z.js → panel-flows-B8TRhcoe.js} +1 -1
- package/src/console/ui/dist/assets/{panel-gates-BQGYXvjT.js → panel-gates-CfkBgco7.js} +1 -1
- package/src/console/ui/dist/assets/{panel-overview-BBnRO18l.js → panel-overview-DONDzBd2.js} +1 -1
- package/src/console/ui/dist/assets/{panel-plugins-D0PsmVw2.js → panel-plugins-DDJwTehL.js} +1 -1
- package/src/console/ui/dist/assets/{panel-runs-CWuRDe0r.js → panel-runs-D5zf-D9c.js} +1 -1
- package/src/console/ui/dist/assets/{panel-signals-Bbg4ewpP.js → panel-signals-Dsxdu_AR.js} +1 -1
- package/src/console/ui/dist/assets/{panel-store-CPCbsDRa.js → panel-store-hy7O8HOs.js} +1 -1
- package/src/console/ui/dist/assets/{panel-traces-DVAzuA_S.js → panel-traces-CBiAe5go.js} +1 -1
- package/src/console/ui/dist/assets/{panel-vault-D1_MvOmo.js → panel-vault-Za1GyJfM.js} +1 -1
- package/src/console/ui/dist/index.html +1 -1
- package/src/console/ui/shell/components/ui.tsx +5 -1
- package/src/console/ui/shell/setup/Wizard.tsx +26 -5
- package/src/docker/cleanup.test.ts +193 -0
- package/src/docker/cleanup.ts +355 -0
- package/src/docker/docker.test.ts +4 -0
- package/src/docker/dockerfile.integration.test.ts +126 -119
- package/src/docker/index.ts +19 -0
- package/src/docker/recipes/ollama.ts +10 -5
- package/src/docker/stack.integration.test.ts +118 -102
- package/src/drivers/ai-ollama-tools.integration.test.ts +8 -6
- package/src/drivers/ai-ollama.integration.test.ts +3 -19
- package/src/drivers/ai-ollama.ts +45 -2
- package/src/drivers/ai-providers.test.ts +31 -0
- package/src/drivers/channel-fcm.ts +49 -53
- package/src/drivers/channel-msegat.ts +61 -0
- package/src/drivers/channel-sently-map.ts +57 -0
- package/src/drivers/channel-sently.test.ts +99 -0
- package/src/drivers/channel-sndr.ts +28 -0
- package/src/drivers/channel-taqnyat.ts +57 -0
- package/src/drivers/channel-types.ts +79 -2
- package/src/drivers/channel-unifonic.ts +26 -43
- package/src/drivers/channel-wa-cloud.ts +33 -47
- package/src/drivers/channel-webpush.ts +39 -239
- package/src/drivers/index.ts +7 -0
- package/src/drivers/signal-engine.ts +98 -25
- package/src/drivers/signal-nats.ts +4 -4
- package/src/drivers/signal-postgres.ts +158 -64
- package/src/drivers/signal-redis.ts +4 -4
- package/src/drivers/signal-types.ts +61 -2
- package/src/drivers/vault-driver-removal.test.ts +5 -1
- package/src/elements/channel/costs.test.ts +2 -2
- package/src/elements/channel/costs.ts +14 -2
- package/src/elements/channel/mime.ts +11 -0
- package/src/elements/channel/runtime.ts +94 -0
- package/src/elements/channel/sndr-webhooks.test.ts +26 -0
- package/src/elements/channel.ts +10 -1
- package/src/elements/clock/chaos-child.ts +150 -0
- package/src/elements/clock/chaos.test.ts +204 -0
- package/src/elements/clock/reconcile.ts +110 -0
- package/src/elements/clock/runtime.ts +11 -0
- package/src/elements/clock.test.ts +164 -2
- package/src/elements/clock.ts +1 -0
- package/src/elements/gate/runtime.ts +6 -0
- package/src/elements/index.ts +9 -0
- package/src/elements/signal/chaos-child.ts +41 -4
- package/src/elements/signal/declare.ts +4 -1
- package/src/elements/signal/delivery-modes.test.ts +179 -0
- package/src/elements/signal/key-ordering.test.ts +306 -0
- package/src/elements/signal/lease-reclaim.test.ts +123 -0
- package/src/elements/signal/optional-emit.test.ts +76 -0
- package/src/elements/signal/order-lifecycle.test.ts +130 -0
- package/src/elements/signal/orphan-messages.test.ts +152 -0
- package/src/elements/signal/runtime.ts +11 -4
- package/src/elements/signal/schema-emit.test.ts +107 -0
- package/src/elements/signal.test.ts +89 -5
- package/src/elements/store/files-fx.ts +104 -0
- package/src/elements/store/files-image.test.ts +251 -0
- package/src/elements/store/files-image.ts +494 -0
- package/src/elements/store/runtime.ts +60 -0
- package/src/elements/store.ts +24 -0
- package/src/elements/vault/boot-chain.ts +150 -0
- package/src/elements/vault/runtime.ts +8 -0
- package/src/kernel/app.ts +3 -1
- package/src/kernel/boot-bind/channel.test.ts +68 -3
- package/src/kernel/boot-bind/channel.ts +93 -2
- package/src/kernel/boot-bind/clock.ts +52 -7
- package/src/kernel/boot-bind/gate.ts +71 -3
- package/src/kernel/boot-bind/honor-config.test.ts +247 -0
- package/src/kernel/boot-bind/signal.ts +64 -7
- package/src/kernel/boot-bind/vault.ts +27 -8
- package/src/kernel/boot.ts +10 -2
- package/src/kernel/errors.ts +8 -2
- package/src/kernel/fx.test.ts +13 -0
- package/src/kernel/fx.ts +27 -4
- package/src/plugins/auth-delivery.mailpit.integration.test.ts +10 -4
- package/src/release/exports.test.ts +26 -0
- package/src/release/exports.ts +64 -5
- package/src/release/index.ts +5 -0
- package/src/release/measure.exports.test.ts +13 -1
- package/src/release/measure.ts +76 -13
- package/src/release/official-plugins.ts +46 -0
- package/src/release/readme.test.ts +30 -2
- package/src/runtime/dev-request-log.test.ts +20 -1
- package/src/runtime/dev-request-log.ts +38 -0
- package/src/term.test.ts +15 -0
- package/src/term.ts +8 -3
- package/src/console/ui/dist/assets/index-CjxwRGVv.js +0 -10
|
@@ -246,10 +246,104 @@ export function createChannelRuntime(options: CreateChannelRuntimeOptions = {}):
|
|
|
246
246
|
}
|
|
247
247
|
}
|
|
248
248
|
|
|
249
|
+
async function sendViaSmsFallback(
|
|
250
|
+
chain: ChannelDriver[],
|
|
251
|
+
message: ChannelMessage,
|
|
252
|
+
): Promise<{ result: ChannelSendResult; attempts: ChannelAttempt[] } | undefined> {
|
|
253
|
+
const sms = chain
|
|
254
|
+
.map((d) => (d.smsTransport ? { driver: d, transport: d.smsTransport } : undefined))
|
|
255
|
+
.filter(
|
|
256
|
+
(
|
|
257
|
+
x,
|
|
258
|
+
): x is { driver: ChannelDriver; transport: NonNullable<ChannelDriver["smsTransport"]> } =>
|
|
259
|
+
!!x,
|
|
260
|
+
);
|
|
261
|
+
if (sms.length === 0) return undefined;
|
|
262
|
+
|
|
263
|
+
const attempts: ChannelAttempt[] = [];
|
|
264
|
+
const transports = sms.map(({ transport }) =>
|
|
265
|
+
options.retry ? new RetryTransport(transport) : transport,
|
|
266
|
+
);
|
|
267
|
+
const fallback = new FallbackTransport(transports, {
|
|
268
|
+
onFallback(failedIndex, error) {
|
|
269
|
+
const provider =
|
|
270
|
+
transports[failedIndex]?.provider ?? sms[failedIndex]?.driver.id ?? `sms-${failedIndex}`;
|
|
271
|
+
attempts.push({
|
|
272
|
+
driverId: provider,
|
|
273
|
+
ok: false,
|
|
274
|
+
error: error instanceof Error ? error.message : String(error),
|
|
275
|
+
at: now(),
|
|
276
|
+
});
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
const body = {
|
|
281
|
+
to: message.to,
|
|
282
|
+
body: message.text ?? String(message.data?.code ?? ""),
|
|
283
|
+
...(message.from ? { from: message.from } : {}),
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
try {
|
|
287
|
+
const sendResult = await fallback.send(body);
|
|
288
|
+
const driverId =
|
|
289
|
+
sendResult.provider ??
|
|
290
|
+
transports[sendResult.providerIndex ?? 0]?.provider ??
|
|
291
|
+
sms[0]?.driver.id ??
|
|
292
|
+
"sms";
|
|
293
|
+
attempts.push({
|
|
294
|
+
driverId,
|
|
295
|
+
ok: true,
|
|
296
|
+
at: now(),
|
|
297
|
+
messageId: sendResult.messageId,
|
|
298
|
+
});
|
|
299
|
+
return {
|
|
300
|
+
result: {
|
|
301
|
+
ok: true,
|
|
302
|
+
messageId: sendResult.messageId,
|
|
303
|
+
driverId,
|
|
304
|
+
attempts,
|
|
305
|
+
},
|
|
306
|
+
attempts,
|
|
307
|
+
};
|
|
308
|
+
} catch (err) {
|
|
309
|
+
const fbAttempts =
|
|
310
|
+
err &&
|
|
311
|
+
typeof err === "object" &&
|
|
312
|
+
"attempts" in err &&
|
|
313
|
+
Array.isArray((err as { attempts: FallbackAttempt[] }).attempts)
|
|
314
|
+
? (err as { attempts: FallbackAttempt[] }).attempts
|
|
315
|
+
: [];
|
|
316
|
+
for (const a of fbAttempts) {
|
|
317
|
+
if (!attempts.some((x) => x.driverId === a.provider && !x.ok)) {
|
|
318
|
+
attempts.push({
|
|
319
|
+
driverId: a.provider,
|
|
320
|
+
ok: false,
|
|
321
|
+
error: a.error instanceof Error ? a.error.message : String(a.error),
|
|
322
|
+
at: now(),
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return {
|
|
327
|
+
result: {
|
|
328
|
+
ok: false,
|
|
329
|
+
messageId: crypto.randomUUID(),
|
|
330
|
+
driverId: "fallback",
|
|
331
|
+
attempts,
|
|
332
|
+
},
|
|
333
|
+
attempts,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
249
338
|
async function sendViaChannelChain(
|
|
250
339
|
chain: ChannelDriver[],
|
|
251
340
|
message: ChannelMessage,
|
|
252
341
|
): Promise<{ result: ChannelSendResult; attempts: ChannelAttempt[] }> {
|
|
342
|
+
if (message.medium === "sms") {
|
|
343
|
+
const viaSms = await sendViaSmsFallback(chain, message);
|
|
344
|
+
if (viaSms) return viaSms;
|
|
345
|
+
}
|
|
346
|
+
|
|
253
347
|
const attempts: ChannelAttempt[] = [];
|
|
254
348
|
for (const d of chain) {
|
|
255
349
|
if (!d.channel) continue;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SNDR webhook helpers re-exported from sently.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { parseSndrWebhook } from "./mime.ts";
|
|
7
|
+
|
|
8
|
+
describe("parseSndrWebhook", () => {
|
|
9
|
+
test("normalizes a delivery event payload", () => {
|
|
10
|
+
const events = parseSndrWebhook({
|
|
11
|
+
type: "email.delivered",
|
|
12
|
+
data: {
|
|
13
|
+
email_id: "em_test",
|
|
14
|
+
to: ["user@example.com"],
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
expect(events).toEqual([
|
|
18
|
+
expect.objectContaining({
|
|
19
|
+
provider: "sndr",
|
|
20
|
+
type: "delivered",
|
|
21
|
+
messageId: "em_test",
|
|
22
|
+
recipient: "user@example.com",
|
|
23
|
+
}),
|
|
24
|
+
]);
|
|
25
|
+
});
|
|
26
|
+
});
|
package/src/elements/channel.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Channel element — reaching humans.
|
|
3
3
|
*
|
|
4
4
|
* Physics: email · SMS · WhatsApp · push.
|
|
5
|
-
* Drivers: `console` · `smtp` · `resend` · `unifonic` · `wa-cloud` · `fcm` · `webpush`.
|
|
5
|
+
* Drivers: `console` · `smtp` · `resend` · `sndr` · `taqnyat` · `msegat` · `unifonic` · `wa-cloud` · `fcm` · `webpush`.
|
|
6
6
|
*
|
|
7
7
|
* Transport interface is identical to sently's so its transports run unchanged.
|
|
8
8
|
* MIME, attachments, retry, and the unified error hierarchy come from sently.
|
|
@@ -91,9 +91,18 @@ export {
|
|
|
91
91
|
RetryTransport,
|
|
92
92
|
FallbackTransport,
|
|
93
93
|
FallbackError,
|
|
94
|
+
toChannelSendResult,
|
|
95
|
+
toDeliveryEvent,
|
|
96
|
+
parseSndrWebhook,
|
|
97
|
+
verifySndrSignature,
|
|
98
|
+
parseUnifonicWebhook,
|
|
94
99
|
type Attachment,
|
|
95
100
|
type MailOptions,
|
|
96
101
|
type SendResult,
|
|
97
102
|
type Transport,
|
|
98
103
|
type FallbackAttempt,
|
|
104
|
+
type EmailEvent,
|
|
105
|
+
type DeliveryEvent,
|
|
106
|
+
type AnySendResult,
|
|
107
|
+
type SentlyChannelSendResult,
|
|
99
108
|
} from "./channel/mime.ts";
|
|
@@ -0,0 +1,150 @@
|
|
|
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
|
+
* durable-mid-step <journalPath> <markerPath> <runIdPath>
|
|
8
|
+
*
|
|
9
|
+
* Modes:
|
|
10
|
+
* tick-loop — reconcile + tick until this instance fires once, append fire log
|
|
11
|
+
* hold-lease — acquire lease, write marker, hang (parent SIGKILLs)
|
|
12
|
+
* durable-mid-step — journal step 1, write marker + runId, hang (parent SIGKILLs)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
|
|
17
|
+
import { flow } from "../../kernel/flow.ts";
|
|
18
|
+
import { createFileJournalStore } from "../../kernel/journal.ts";
|
|
19
|
+
import { clock } from "./declare.ts";
|
|
20
|
+
import { runDurable } from "./durable.ts";
|
|
21
|
+
import { createFileCronStore } from "./reconcile.ts";
|
|
22
|
+
import { createClockRuntime } from "./runtime.ts";
|
|
23
|
+
|
|
24
|
+
const mode = process.argv[2];
|
|
25
|
+
|
|
26
|
+
if (!mode) {
|
|
27
|
+
console.error("usage: chaos-child <tick-loop|hold-lease|durable-mid-step> …");
|
|
28
|
+
process.exit(2);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const CRON_NAME = "chaos-job";
|
|
32
|
+
|
|
33
|
+
if (mode === "tick-loop") {
|
|
34
|
+
const storePath = process.argv[3];
|
|
35
|
+
const instanceId = process.argv[4];
|
|
36
|
+
const fireLogPath = process.argv[5];
|
|
37
|
+
const leaseMs = Number(process.argv[6] ?? 100);
|
|
38
|
+
// Default 1h so a second process cannot become due again within the test window
|
|
39
|
+
// after the winner sets lastRunAt (proves one fire per tick, not one per lease).
|
|
40
|
+
const every = process.argv[7] ?? "1h";
|
|
41
|
+
if (!storePath || !instanceId || !fireLogPath) {
|
|
42
|
+
console.error(
|
|
43
|
+
"usage: chaos-child tick-loop <storePath> <instanceId> <fireLogPath> <leaseMs> [every]",
|
|
44
|
+
);
|
|
45
|
+
process.exit(2);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const store = createFileCronStore(join(storePath));
|
|
49
|
+
const rt = createClockRuntime({
|
|
50
|
+
instanceId,
|
|
51
|
+
store,
|
|
52
|
+
leaseMs: Number.isFinite(leaseMs) ? leaseMs : 100,
|
|
53
|
+
});
|
|
54
|
+
rt.register(clock(CRON_NAME, { every }));
|
|
55
|
+
await rt.reconcile();
|
|
56
|
+
rt.onCron(CRON_NAME, async () => {
|
|
57
|
+
const line = `${JSON.stringify({ instanceId, firedAt: Date.now() })}\n`;
|
|
58
|
+
const prev = (await Bun.file(fireLogPath).exists()) ? await Bun.file(fireLogPath).text() : "";
|
|
59
|
+
await Bun.write(fireLogPath, prev + line);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const deadline = Date.now() + 15_000;
|
|
63
|
+
while (Date.now() < deadline) {
|
|
64
|
+
const { ran } = await rt.tick();
|
|
65
|
+
if (ran.includes(CRON_NAME)) process.exit(0);
|
|
66
|
+
await Bun.sleep(5);
|
|
67
|
+
}
|
|
68
|
+
console.error("tick-loop: timed out without firing");
|
|
69
|
+
process.exit(3);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (mode === "hold-lease") {
|
|
73
|
+
const storePath = process.argv[3];
|
|
74
|
+
const instanceId = process.argv[4];
|
|
75
|
+
const markerPath = process.argv[5];
|
|
76
|
+
const leaseMs = Number(process.argv[6] ?? 100);
|
|
77
|
+
if (!storePath || !instanceId || !markerPath) {
|
|
78
|
+
console.error("usage: chaos-child hold-lease <storePath> <instanceId> <markerPath> <leaseMs>");
|
|
79
|
+
process.exit(2);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const store = createFileCronStore(join(storePath));
|
|
83
|
+
const rt = createClockRuntime({
|
|
84
|
+
instanceId,
|
|
85
|
+
store,
|
|
86
|
+
leaseMs: Number.isFinite(leaseMs) ? leaseMs : 100,
|
|
87
|
+
});
|
|
88
|
+
rt.register(clock(CRON_NAME, { every: "50ms" }));
|
|
89
|
+
await rt.reconcile();
|
|
90
|
+
|
|
91
|
+
// Fire once so lastRunAt + lease are set (this instance is leader).
|
|
92
|
+
rt.onCron(CRON_NAME, () => {});
|
|
93
|
+
const ok = await rt.runNow(CRON_NAME);
|
|
94
|
+
if (!ok) {
|
|
95
|
+
console.error("hold-lease: failed to acquire/fire");
|
|
96
|
+
process.exit(3);
|
|
97
|
+
}
|
|
98
|
+
const row = await store.get(CRON_NAME);
|
|
99
|
+
await Bun.write(
|
|
100
|
+
markerPath,
|
|
101
|
+
JSON.stringify({
|
|
102
|
+
instanceId,
|
|
103
|
+
heldAt: Date.now(),
|
|
104
|
+
leaderLeaseUntil: row?.leaderLeaseUntil,
|
|
105
|
+
}),
|
|
106
|
+
);
|
|
107
|
+
// Hang while holding the lease — parent will SIGKILL.
|
|
108
|
+
await Bun.sleep(60_000);
|
|
109
|
+
process.exit(0);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (mode === "durable-mid-step") {
|
|
113
|
+
const journalPath = process.argv[3];
|
|
114
|
+
const markerPath = process.argv[4];
|
|
115
|
+
const runIdPath = process.argv[5];
|
|
116
|
+
if (!journalPath || !markerPath || !runIdPath) {
|
|
117
|
+
console.error("usage: chaos-child durable-mid-step <journalPath> <markerPath> <runIdPath>");
|
|
118
|
+
process.exit(2);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const journalStore = createFileJournalStore(join(journalPath));
|
|
122
|
+
const charge = flow({
|
|
123
|
+
name: "chaos.charge",
|
|
124
|
+
durable: true,
|
|
125
|
+
do: async (_input, fx) => {
|
|
126
|
+
const intent = await fx.step("create-intent", async () => {
|
|
127
|
+
// Persist run id as soon as the step body runs (session already started).
|
|
128
|
+
const runs = await journalStore.list();
|
|
129
|
+
const run = runs[0];
|
|
130
|
+
if (run) await Bun.write(runIdPath, run.id);
|
|
131
|
+
await Bun.write(markerPath, "step1");
|
|
132
|
+
return { id: "pi_chaos" };
|
|
133
|
+
});
|
|
134
|
+
// Hang after journaling step 1 — parent SIGKILLs before confirm.
|
|
135
|
+
await Bun.sleep(60_000);
|
|
136
|
+
return fx.step("confirm", () => intent.id === "pi_chaos");
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
await runDurable({
|
|
141
|
+
flow: charge,
|
|
142
|
+
input: { orderId: "o-chaos" },
|
|
143
|
+
journalStore,
|
|
144
|
+
});
|
|
145
|
+
// Unreachable under SIGKILL.
|
|
146
|
+
process.exit(0);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
console.error(`unknown mode: ${mode}`);
|
|
150
|
+
process.exit(2);
|
|
@@ -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
|
+
});
|
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
* The scheduler reads the effective state from the Store, not the code.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import { mkdir, rename, rm, writeFile } from "node:fs/promises";
|
|
15
|
+
import { dirname, join } from "node:path";
|
|
16
|
+
|
|
14
17
|
import type { ClockDecl } from "./declare.ts";
|
|
15
18
|
|
|
16
19
|
/** Cron lifecycle status in `oke_crons`. */
|
|
@@ -49,6 +52,8 @@ export interface CronRow {
|
|
|
49
52
|
|
|
50
53
|
/** Store surface for reconciled crons. */
|
|
51
54
|
export interface CronStore {
|
|
55
|
+
/** Backing kind when known (`memory` · `file`). */
|
|
56
|
+
readonly kind?: "memory" | "file";
|
|
52
57
|
/**
|
|
53
58
|
* @param name - Cron name
|
|
54
59
|
*/
|
|
@@ -198,6 +203,7 @@ export function createMemoryCronStore(seed?: readonly CronRow[]): CronStore {
|
|
|
198
203
|
}
|
|
199
204
|
|
|
200
205
|
return {
|
|
206
|
+
kind: "memory",
|
|
201
207
|
async get(name) {
|
|
202
208
|
const r = rows.get(name);
|
|
203
209
|
return r ? structuredClone(r) : undefined;
|
|
@@ -228,3 +234,107 @@ export function createMemoryCronStore(seed?: readonly CronRow[]): CronStore {
|
|
|
228
234
|
},
|
|
229
235
|
};
|
|
230
236
|
}
|
|
237
|
+
|
|
238
|
+
/** On-disk shape for {@link createFileCronStore}. */
|
|
239
|
+
interface FileCronSnapshot {
|
|
240
|
+
readonly rows: CronRow[];
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* File-backed cron store for multi-process leader election / chaos tests.
|
|
245
|
+
*
|
|
246
|
+
* Every mutation runs under an exclusive lock directory next to `path` so
|
|
247
|
+
* two OS processes sharing the same file cannot both win a lease race.
|
|
248
|
+
* Does not cache rows across calls (each op re-reads disk).
|
|
249
|
+
*
|
|
250
|
+
* @param path - JSON snapshot path (`{ rows: CronRow[] }`)
|
|
251
|
+
*/
|
|
252
|
+
export function createFileCronStore(path: string): CronStore {
|
|
253
|
+
const lockDir = `${path}.lock`;
|
|
254
|
+
|
|
255
|
+
async function withFileLock<T>(fn: () => Promise<T>): Promise<T> {
|
|
256
|
+
for (let attempt = 0; attempt < 2_000; attempt++) {
|
|
257
|
+
try {
|
|
258
|
+
await mkdir(lockDir);
|
|
259
|
+
break;
|
|
260
|
+
} catch (err) {
|
|
261
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
262
|
+
if (code !== "EEXIST") throw err;
|
|
263
|
+
await Bun.sleep(2);
|
|
264
|
+
if (attempt === 1_999) {
|
|
265
|
+
throw new Error(`createFileCronStore: lock timeout on ${lockDir}`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
try {
|
|
270
|
+
return await fn();
|
|
271
|
+
} finally {
|
|
272
|
+
await rm(lockDir, { recursive: true, force: true });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function loadUnlocked(): Promise<Map<string, CronRow>> {
|
|
277
|
+
const map = new Map<string, CronRow>();
|
|
278
|
+
const file = Bun.file(path);
|
|
279
|
+
if (!(await file.exists())) return map;
|
|
280
|
+
const text = await file.text();
|
|
281
|
+
if (!text.trim()) return map;
|
|
282
|
+
const snap = JSON.parse(text) as FileCronSnapshot;
|
|
283
|
+
for (const r of snap.rows ?? []) {
|
|
284
|
+
map.set(r.name, structuredClone(r));
|
|
285
|
+
}
|
|
286
|
+
return map;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function flushUnlocked(map: Map<string, CronRow>): Promise<void> {
|
|
290
|
+
await mkdir(dirname(path), { recursive: true });
|
|
291
|
+
const tmp = join(dirname(path), `.${Bun.hash(path).toString(16)}.tmp`);
|
|
292
|
+
const body = JSON.stringify({ rows: [...map.values()] } satisfies FileCronSnapshot, null, 2);
|
|
293
|
+
await writeFile(tmp, body, "utf8");
|
|
294
|
+
await rename(tmp, path);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return {
|
|
298
|
+
kind: "file",
|
|
299
|
+
async get(name) {
|
|
300
|
+
return withFileLock(async () => {
|
|
301
|
+
const map = await loadUnlocked();
|
|
302
|
+
const r = map.get(name);
|
|
303
|
+
return r ? structuredClone(r) : undefined;
|
|
304
|
+
});
|
|
305
|
+
},
|
|
306
|
+
async put(row) {
|
|
307
|
+
await withFileLock(async () => {
|
|
308
|
+
const map = await loadUnlocked();
|
|
309
|
+
map.set(row.name, structuredClone(row));
|
|
310
|
+
await flushUnlocked(map);
|
|
311
|
+
});
|
|
312
|
+
},
|
|
313
|
+
async list() {
|
|
314
|
+
return withFileLock(async () => {
|
|
315
|
+
const map = await loadUnlocked();
|
|
316
|
+
return [...map.values()].map((r) => structuredClone(r));
|
|
317
|
+
});
|
|
318
|
+
},
|
|
319
|
+
async acquireLease(name, instanceId, now, leaseMs) {
|
|
320
|
+
return withFileLock(async () => {
|
|
321
|
+
const map = await loadUnlocked();
|
|
322
|
+
const row = map.get(name);
|
|
323
|
+
if (!row) return false;
|
|
324
|
+
const held =
|
|
325
|
+
row.leaderLeaseUntil !== undefined &&
|
|
326
|
+
row.leaderLeaseUntil > now &&
|
|
327
|
+
row.leaderInstanceId !== undefined &&
|
|
328
|
+
row.leaderInstanceId !== instanceId;
|
|
329
|
+
if (held) return false;
|
|
330
|
+
map.set(name, {
|
|
331
|
+
...row,
|
|
332
|
+
leaderInstanceId: instanceId,
|
|
333
|
+
leaderLeaseUntil: now + leaseMs,
|
|
334
|
+
});
|
|
335
|
+
await flushUnlocked(map);
|
|
336
|
+
return true;
|
|
337
|
+
});
|
|
338
|
+
},
|
|
339
|
+
};
|
|
340
|
+
}
|
|
@@ -42,6 +42,11 @@ export interface CreateClockRuntimeOptions {
|
|
|
42
42
|
|
|
43
43
|
/** Clock runtime. */
|
|
44
44
|
export interface ClockRuntime {
|
|
45
|
+
/**
|
|
46
|
+
* Effective driver id selected at construction
|
|
47
|
+
* (`memory` · `file` · `frozen`).
|
|
48
|
+
*/
|
|
49
|
+
readonly driverId: "memory" | "file" | "frozen";
|
|
45
50
|
/** Instance id used for leases. */
|
|
46
51
|
readonly instanceId: string;
|
|
47
52
|
/** Cron store the scheduler reads. */
|
|
@@ -107,6 +112,11 @@ export function createClockRuntime(options: CreateClockRuntimeOptions = {}): Clo
|
|
|
107
112
|
const leaseMs = options.leaseMs ?? 30_000;
|
|
108
113
|
const declarations = new Map<string, ClockDecl>();
|
|
109
114
|
const handlers = new Map<string, CronHandler>();
|
|
115
|
+
const driverId: ClockRuntime["driverId"] = timeTravel
|
|
116
|
+
? "frozen"
|
|
117
|
+
: store.kind === "file"
|
|
118
|
+
? "file"
|
|
119
|
+
: "memory";
|
|
110
120
|
|
|
111
121
|
async function fire(name: string): Promise<boolean> {
|
|
112
122
|
const row = await store.get(name);
|
|
@@ -161,6 +171,7 @@ export function createClockRuntime(options: CreateClockRuntimeOptions = {}): Clo
|
|
|
161
171
|
}
|
|
162
172
|
|
|
163
173
|
const runtime: ClockRuntime = {
|
|
174
|
+
driverId,
|
|
164
175
|
instanceId,
|
|
165
176
|
store,
|
|
166
177
|
declarations,
|