okengine 0.13.0 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/manifest.v1.schema.json +7 -1
- package/package.json +2 -2
- package/site/content/docs/ai/mcp.mdx +27 -2
- package/site/content/docs/elements/ai.mdx +58 -11
- package/site/content/docs/elements/clock.mdx +27 -12
- package/site/content/docs/elements/flow.mdx +6 -3
- package/site/content/docs/elements/signal.mdx +71 -25
- package/site/content/docs/elements/store.mdx +1 -1
- package/site/content/docs/get-started/installation.mdx +2 -2
- package/site/content/docs/reference/fx.mdx +27 -22
- package/src/cli/ai-setup/recommend.test.ts +25 -0
- package/src/cli/ai-setup/recommend.ts +8 -3
- package/src/compiler/effects-infer.ts +58 -3
- package/src/compiler/extract.test.ts +54 -1
- package/src/compiler/extract.ts +69 -2
- package/src/compiler/response.ts +45 -1
- package/src/console/server/ai.ts +5 -2
- package/src/console/server/flows.ts +1 -0
- package/src/console/server/serve.ts +17 -2
- package/src/console/ui-next/dist/assets/cache-glyph-BanhLsEY.js +1 -0
- package/src/console/ui-next/dist/assets/flows-page-DxDsOd4f.js +1 -0
- package/src/console/ui-next/dist/assets/http-method-CJCBYL2j.js +1 -0
- package/src/console/ui-next/dist/assets/{index-Bp-R7jtM.js → index-Ce6WKWKM.js} +3 -3
- package/src/console/ui-next/dist/assets/observability-page-BEZDzyYh.js +4 -0
- package/src/console/ui-next/dist/assets/trace-detail-sheet-DFLFfUUX.js +2 -0
- package/src/console/ui-next/dist/assets/units-page-C0gW6Kdo.js +1 -0
- package/src/console/ui-next/dist/assets/{vault-page-Ca-MvcmJ.js → vault-page-DuKzqwzW.js} +1 -1
- package/src/console/ui-next/dist/index.html +1 -1
- package/src/console/ui-next/seed-invoke-host.ts +2 -0
- package/src/console/ui-next/src/features/flows/graph/build-flow-graph.test.ts +18 -0
- package/src/console/ui-next/src/features/flows/graph/build-flow-graph.ts +14 -1
- package/src/console/ui-next/src/features/flows/graph/neighborhood.test.ts +17 -0
- package/src/console/ui-next/src/features/flows/graph/neighborhood.ts +16 -3
- package/src/console/ui-next/src/features/flows/traces/effect-kind.ts +3 -1
- package/src/console/ui-next/src/features/flows/traces/effect-summary.ts +24 -0
- package/src/console/ui-next/src/features/flows/traces/trace-detail-sheet.tsx +9 -3
- package/src/console/ui-next/src/features/flows/traces/trace-detail.test.ts +10 -1
- package/src/console/ui-next/src/features/observability/lib/ask-count.test.ts +25 -0
- package/src/console/ui-next/src/features/observability/lib/ask-count.ts +4 -1
- package/src/console/ui-next/src/features/units/detail/effects-summary.tsx +12 -4
- package/src/docker/docker.test.ts +1 -1
- package/src/docker/dockerfile.ts +1 -1
- package/src/drivers/ai-anthropic.ts +5 -0
- package/src/drivers/ai-ollama.ts +49 -30
- package/src/drivers/ai-openai-compatible.ts +57 -46
- package/src/drivers/ai-providers.test.ts +3 -0
- package/src/drivers/bun-native-completeness.test.ts +7 -9
- package/src/drivers/redis.ts +11 -4
- package/src/drivers/signal-redis.ts +24 -14
- package/src/drivers/signal-types.ts +2 -1
- package/src/elements/ai/declare.ts +109 -0
- package/src/elements/ai/errors.test.ts +5 -1
- package/src/elements/ai/errors.ts +30 -2
- package/src/elements/ai/eval.ts +4 -6
- package/src/elements/ai/mcp-client.test.ts +206 -0
- package/src/elements/ai/mcp-client.ts +362 -0
- package/src/elements/ai/mcp-http.ts +159 -0
- package/src/elements/ai/mcp-mock.ts +134 -0
- package/src/elements/ai/mcp-protocol.ts +234 -0
- package/src/elements/ai/mcp-stdio.test.ts +50 -0
- package/src/elements/ai/mcp-stdio.ts +212 -0
- package/src/elements/ai/mcp-transport.ts +70 -0
- package/src/elements/ai/runtime.ts +159 -29
- package/src/elements/ai.test.ts +139 -0
- package/src/elements/ai.ts +16 -0
- package/src/elements/clock/health.test.ts +43 -0
- package/src/elements/clock/runtime.ts +55 -2
- package/src/elements/clock/schedule.ts +71 -142
- package/src/elements/clock.test.ts +9 -40
- package/src/elements/clock.ts +6 -1
- package/src/elements/index.ts +2 -0
- package/src/elements/signal/declare.ts +2 -1
- package/src/elements/signal/runtime.ts +16 -1
- package/src/elements/signal.ts +1 -0
- package/src/elements/store/cache.test.ts +2 -0
- package/src/elements/store/cache.ts +3 -3
- package/src/elements/vault/builtin-adapter.ts +17 -0
- package/src/full.ts +2 -0
- package/src/index.ts +4 -0
- package/src/kernel/app.ts +97 -64
- package/src/kernel/auto-registry.test.ts +5 -0
- package/src/kernel/boot-bind/ai.ts +24 -0
- package/src/kernel/boot-bind/clock.ts +2 -0
- package/src/kernel/boot.ts +3 -1
- package/src/kernel/element-registries.ts +4 -1
- package/src/kernel/fx-dead-letters.test.ts +77 -0
- package/src/kernel/fx.test.ts +22 -0
- package/src/kernel/fx.ts +112 -6
- package/src/kernel/http-stream.test.ts +174 -0
- package/src/kernel/index.ts +2 -0
- package/src/manifest/mcp-ref.ts +88 -0
- package/src/manifest/types.ts +28 -2
- package/src/manifest/validate.test.ts +20 -0
- package/src/mcp/docs-server.ts +1 -1
- package/src/mcp/server.ts +1 -1
- package/src/plugins/compression.test.ts +21 -0
- package/src/plugins/compression.ts +1 -0
- package/src/runtime/bun.ts +41 -4
- package/src/test/reset-element-registries.ts +2 -0
- package/src/console/ui-next/dist/assets/cache-glyph-CLPBqZeb.js +0 -1
- package/src/console/ui-next/dist/assets/flows-page-Dg8CTE29.js +0 -1
- package/src/console/ui-next/dist/assets/http-method-BJ92Z_ke.js +0 -1
- package/src/console/ui-next/dist/assets/observability-page-DAnpEaq1.js +0 -4
- package/src/console/ui-next/dist/assets/trace-detail-sheet-D16lWQMt.js +0 -2
- package/src/console/ui-next/dist/assets/units-page-BtQ0bqMe.js +0 -1
package/src/elements/ai.test.ts
CHANGED
|
@@ -51,6 +51,21 @@ describe("ai declaration", () => {
|
|
|
51
51
|
});
|
|
52
52
|
expect(embed.into).toBe("kb");
|
|
53
53
|
});
|
|
54
|
+
|
|
55
|
+
test("mcpServer requires allowlist and .tool() NamedRef", () => {
|
|
56
|
+
const github = ai.mcpServer("github", {
|
|
57
|
+
url: "https://mcp.example/github",
|
|
58
|
+
tools: ["create_issue"],
|
|
59
|
+
});
|
|
60
|
+
expect(github.kind).toBe("mcp-server");
|
|
61
|
+
expect(github.tools).toEqual(["create_issue"]);
|
|
62
|
+
expect(github.tool("create_issue")).toEqual({ name: "mcp:github/create_issue" });
|
|
63
|
+
expect(() => github.tool("delete_repo")).toThrow(/not in the allowlist/);
|
|
64
|
+
expect(() => ai.mcpServer("broken", { url: "https://x" } as never)).toThrow(/allowlist/);
|
|
65
|
+
expect(() => ai.mcpServer("both", { url: "https://x", command: "npx", tools: ["a"] })).toThrow(
|
|
66
|
+
/exactly one/,
|
|
67
|
+
);
|
|
68
|
+
});
|
|
54
69
|
});
|
|
55
70
|
|
|
56
71
|
describe("agent gate denial is recorded", () => {
|
|
@@ -576,3 +591,127 @@ describe("ask journal tokens", () => {
|
|
|
576
591
|
expect(entry.cost).toBe(0);
|
|
577
592
|
});
|
|
578
593
|
});
|
|
594
|
+
|
|
595
|
+
describe("ask budget and cancel", () => {
|
|
596
|
+
test("maxCostPerCall stops a successful over-budget ask", async () => {
|
|
597
|
+
const smart = ai.model("smart", { provider: "mock" });
|
|
598
|
+
const prompt = smart.prompt("pricey", { budget: { maxCostPerCall: 0.01 } });
|
|
599
|
+
const runtime = createAiRuntime({
|
|
600
|
+
models: [smart],
|
|
601
|
+
prompts: [prompt],
|
|
602
|
+
clients: {
|
|
603
|
+
smart: {
|
|
604
|
+
driverId: "mock",
|
|
605
|
+
model: "smart",
|
|
606
|
+
async complete() {
|
|
607
|
+
return {
|
|
608
|
+
text: "{}",
|
|
609
|
+
raw: {},
|
|
610
|
+
model: "smart",
|
|
611
|
+
driverId: "mock",
|
|
612
|
+
usage: { cost: 0.5 },
|
|
613
|
+
};
|
|
614
|
+
},
|
|
615
|
+
},
|
|
616
|
+
},
|
|
617
|
+
});
|
|
618
|
+
await expect(runtime.ask("pricey", {})).rejects.toThrow(/maxCostPerCall/);
|
|
619
|
+
expect(runtime.journal[0]!.outcome).toBe("budget_exceeded");
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
test("disconnect AbortError does not advance via", async () => {
|
|
623
|
+
let localCalls = 0;
|
|
624
|
+
const smart = ai.model("smart", { provider: "mock" });
|
|
625
|
+
const local = ai.model("local", { provider: "mock" });
|
|
626
|
+
const prompt = smart.prompt("cancel-me", { via: ["smart", "local"] });
|
|
627
|
+
const runtime = createAiRuntime({
|
|
628
|
+
models: [smart, local],
|
|
629
|
+
prompts: [prompt],
|
|
630
|
+
clients: {
|
|
631
|
+
smart: {
|
|
632
|
+
driverId: "mock",
|
|
633
|
+
model: "smart",
|
|
634
|
+
async complete() {
|
|
635
|
+
const err = new Error("aborted");
|
|
636
|
+
err.name = "AbortError";
|
|
637
|
+
throw err;
|
|
638
|
+
},
|
|
639
|
+
},
|
|
640
|
+
local: {
|
|
641
|
+
driverId: "mock",
|
|
642
|
+
model: "local",
|
|
643
|
+
async complete() {
|
|
644
|
+
localCalls++;
|
|
645
|
+
return { text: "{}", raw: {}, model: "local", driverId: "mock" };
|
|
646
|
+
},
|
|
647
|
+
},
|
|
648
|
+
},
|
|
649
|
+
});
|
|
650
|
+
await expect(runtime.ask("cancel-me", {})).rejects.toThrow(/aborted/);
|
|
651
|
+
expect(localCalls).toBe(0);
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
test("driverId opens the matching protocol driver", async () => {
|
|
655
|
+
let opened = "";
|
|
656
|
+
const local = ai.model("local", { driverId: "ollama", model: "llama" });
|
|
657
|
+
const prompt = local.prompt("ping");
|
|
658
|
+
const runtime = createAiRuntime({
|
|
659
|
+
models: [local],
|
|
660
|
+
prompts: [prompt],
|
|
661
|
+
drivers: {
|
|
662
|
+
ollama: {
|
|
663
|
+
id: "ollama",
|
|
664
|
+
async open() {
|
|
665
|
+
opened = "ollama";
|
|
666
|
+
return {
|
|
667
|
+
driverId: "ollama",
|
|
668
|
+
model: "llama",
|
|
669
|
+
async complete() {
|
|
670
|
+
return { text: "{}", raw: {}, model: "llama", driverId: "ollama" };
|
|
671
|
+
},
|
|
672
|
+
};
|
|
673
|
+
},
|
|
674
|
+
},
|
|
675
|
+
},
|
|
676
|
+
});
|
|
677
|
+
await runtime.ask("ping", {});
|
|
678
|
+
expect(opened).toBe("ollama");
|
|
679
|
+
});
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
describe("stream via", () => {
|
|
683
|
+
test("advances to the next model when the first stream fails before a chunk", async () => {
|
|
684
|
+
const smart = ai.model("smart");
|
|
685
|
+
const local = ai.model("local");
|
|
686
|
+
const runtime = createAiRuntime({
|
|
687
|
+
models: [smart, local],
|
|
688
|
+
clients: {
|
|
689
|
+
smart: {
|
|
690
|
+
driverId: "mock",
|
|
691
|
+
model: "smart",
|
|
692
|
+
async complete() {
|
|
693
|
+
return { text: "", model: "smart", driverId: "mock" };
|
|
694
|
+
},
|
|
695
|
+
async *stream() {
|
|
696
|
+
throw new Error("smart stream down");
|
|
697
|
+
},
|
|
698
|
+
},
|
|
699
|
+
local: {
|
|
700
|
+
driverId: "mock",
|
|
701
|
+
model: "local",
|
|
702
|
+
async complete() {
|
|
703
|
+
return { text: "", model: "local", driverId: "mock" };
|
|
704
|
+
},
|
|
705
|
+
async *stream() {
|
|
706
|
+
yield { text: "ok" };
|
|
707
|
+
},
|
|
708
|
+
},
|
|
709
|
+
},
|
|
710
|
+
});
|
|
711
|
+
const parts: string[] = [];
|
|
712
|
+
for await (const c of runtime.stream("smart", { prompt: "hi", via: ["local"] })) {
|
|
713
|
+
parts.push(c);
|
|
714
|
+
}
|
|
715
|
+
expect(parts.join("")).toBe("ok");
|
|
716
|
+
});
|
|
717
|
+
});
|
package/src/elements/ai.ts
CHANGED
|
@@ -19,6 +19,10 @@ export type {
|
|
|
19
19
|
AiBudgetDecl,
|
|
20
20
|
AiEmbedDecl,
|
|
21
21
|
AiEmbedOptions,
|
|
22
|
+
AiMcpServerAuthOptions,
|
|
23
|
+
AiMcpServerDecl,
|
|
24
|
+
AiMcpServerOptions,
|
|
25
|
+
AiMcpToolRef,
|
|
22
26
|
AiModelDecl,
|
|
23
27
|
AiModelOptions,
|
|
24
28
|
AiPromptDecl,
|
|
@@ -58,6 +62,18 @@ export type {
|
|
|
58
62
|
CreateAiRuntimeOptions,
|
|
59
63
|
} from "./ai/runtime.ts";
|
|
60
64
|
|
|
65
|
+
export { createMcpClient } from "./ai/mcp-client.ts";
|
|
66
|
+
export type { CreateMcpClientOptions, McpClient, McpResolvedTool } from "./ai/mcp-client.ts";
|
|
67
|
+
export { createMockMcpTransport } from "./ai/mcp-mock.ts";
|
|
68
|
+
export type { CreateMockMcpTransportOptions, MockMcpTool } from "./ai/mcp-mock.ts";
|
|
69
|
+
export {
|
|
70
|
+
MCP_CLIENT_PROTOCOL_VERSION,
|
|
71
|
+
MCP_LEGACY_PROTOCOL_VERSION,
|
|
72
|
+
mcpClientMeta,
|
|
73
|
+
parseJsonRpcResponse,
|
|
74
|
+
} from "./ai/mcp-protocol.ts";
|
|
75
|
+
export type { McpListedTool, McpProtocolEra } from "./ai/mcp-protocol.ts";
|
|
76
|
+
|
|
61
77
|
export { AI_RATE_PRESETS, aiRateGate, createAiRateGates } from "./ai/rate.ts";
|
|
62
78
|
export type { AiRatePreset } from "./ai/rate.ts";
|
|
63
79
|
|
|
@@ -100,4 +100,47 @@ describe("nextOccurrences", () => {
|
|
|
100
100
|
expect(fires.length).toBeGreaterThanOrEqual(1);
|
|
101
101
|
expect(fires.every((t) => t >= from && t < until)).toBe(true);
|
|
102
102
|
});
|
|
103
|
+
|
|
104
|
+
test("*/15 and @hourly via Bun.cron.parse", () => {
|
|
105
|
+
const from = Date.UTC(2024, 0, 1, 0, 0, 0);
|
|
106
|
+
const until = from + 60 * 60_000;
|
|
107
|
+
const quarters = nextOccurrences(
|
|
108
|
+
row({
|
|
109
|
+
name: "quarters",
|
|
110
|
+
effectiveCron: "*/15 * * * *",
|
|
111
|
+
timezone: "UTC",
|
|
112
|
+
}),
|
|
113
|
+
from,
|
|
114
|
+
until,
|
|
115
|
+
);
|
|
116
|
+
expect(quarters).toEqual([from, from + 15 * 60_000, from + 30 * 60_000, from + 45 * 60_000]);
|
|
117
|
+
|
|
118
|
+
const hours = nextOccurrences(
|
|
119
|
+
row({
|
|
120
|
+
name: "hourly",
|
|
121
|
+
effectiveCron: "@hourly",
|
|
122
|
+
timezone: "UTC",
|
|
123
|
+
}),
|
|
124
|
+
from,
|
|
125
|
+
from + 3 * 3_600_000,
|
|
126
|
+
);
|
|
127
|
+
expect(hours).toEqual([from, from + 3_600_000, from + 2 * 3_600_000]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("cron timezone is the declared IANA zone", () => {
|
|
131
|
+
const from = Date.UTC(2024, 0, 1, 0, 0, 0);
|
|
132
|
+
const until = from + 36 * 3_600_000;
|
|
133
|
+
const fires = nextOccurrences(
|
|
134
|
+
row({
|
|
135
|
+
name: "ny",
|
|
136
|
+
effectiveCron: "0 9 * * *",
|
|
137
|
+
timezone: "America/New_York",
|
|
138
|
+
}),
|
|
139
|
+
from,
|
|
140
|
+
until,
|
|
141
|
+
);
|
|
142
|
+
expect(fires).toHaveLength(1);
|
|
143
|
+
// 09:00 America/New_York on 2024-01-01 is 14:00 UTC (EST).
|
|
144
|
+
expect(fires[0]).toBe(Date.UTC(2024, 0, 1, 14, 0, 0));
|
|
145
|
+
});
|
|
103
146
|
});
|
|
@@ -11,6 +11,7 @@ import type { ClockDecl } from "./declare.ts";
|
|
|
11
11
|
import { detectDstAmbiguity } from "./dst.ts";
|
|
12
12
|
import { parseDurationMs } from "./duration.ts";
|
|
13
13
|
import { tryAcquireLease } from "./leader.ts";
|
|
14
|
+
import { nextCronFireAt } from "./schedule.ts";
|
|
14
15
|
import {
|
|
15
16
|
createMemoryCronStore,
|
|
16
17
|
effectiveSchedule,
|
|
@@ -98,6 +99,13 @@ export interface ClockRuntime {
|
|
|
98
99
|
* @param name - Cron name
|
|
99
100
|
*/
|
|
100
101
|
dstAmbiguity(name: string): Promise<ReturnType<typeof detectDstAmbiguity>>;
|
|
102
|
+
/**
|
|
103
|
+
* Register in-process {@link Bun.cron} wakes for declared cron expressions.
|
|
104
|
+
* No-op on a frozen / time-travel runtime (tests drive {@link tick}).
|
|
105
|
+
*/
|
|
106
|
+
startWakes(): void;
|
|
107
|
+
/** Stop every in-process {@link Bun.cron} wake registered by {@link startWakes}. */
|
|
108
|
+
stopWakes(): void;
|
|
101
109
|
}
|
|
102
110
|
|
|
103
111
|
/**
|
|
@@ -115,6 +123,8 @@ export function createClockRuntime(options: CreateClockRuntimeOptions = {}): Clo
|
|
|
115
123
|
const handlers = new Map<string, CronHandler>();
|
|
116
124
|
/** The 1s boot scheduler must not stack ticks while a handler is still running. */
|
|
117
125
|
let ticking = false;
|
|
126
|
+
/** In-process {@link Bun.cron} jobs — precise wake; {@link tick} still owns the lease. */
|
|
127
|
+
const wakes: Array<{ stop(): void }> = [];
|
|
118
128
|
const driverId: ClockRuntime["driverId"] = timeTravel
|
|
119
129
|
? "frozen"
|
|
120
130
|
: store.kind === "file"
|
|
@@ -154,11 +164,18 @@ export function createClockRuntime(options: CreateClockRuntimeOptions = {}): Clo
|
|
|
154
164
|
|
|
155
165
|
// Claim the slot before the handler so a stacked 1s tick cannot re-enter.
|
|
156
166
|
const ranAt = now();
|
|
157
|
-
const
|
|
167
|
+
const sched = effectiveSchedule(fresh);
|
|
168
|
+
let nextRunAt = fresh.nextRunAt;
|
|
169
|
+
if (sched.every) {
|
|
170
|
+
const interval = parseDurationMs(sched.every);
|
|
171
|
+
if (interval > 0) nextRunAt = ranAt + interval;
|
|
172
|
+
} else if (sched.cron) {
|
|
173
|
+
nextRunAt = nextCronFireAt(sched.cron, ranAt, fresh.timezone) ?? fresh.nextRunAt;
|
|
174
|
+
}
|
|
158
175
|
await store.put({
|
|
159
176
|
...fresh,
|
|
160
177
|
lastRunAt: ranAt,
|
|
161
|
-
nextRunAt
|
|
178
|
+
nextRunAt,
|
|
162
179
|
});
|
|
163
180
|
|
|
164
181
|
const handler = handlers.get(name);
|
|
@@ -250,6 +267,42 @@ export function createClockRuntime(options: CreateClockRuntimeOptions = {}): Clo
|
|
|
250
267
|
if (!row?.effectiveCron) return null;
|
|
251
268
|
return detectDstAmbiguity(row.effectiveCron, row.timezone, now());
|
|
252
269
|
},
|
|
270
|
+
startWakes() {
|
|
271
|
+
runtime.stopWakes();
|
|
272
|
+
if (timeTravel) return;
|
|
273
|
+
for (const decl of declarations.values()) {
|
|
274
|
+
if (!decl.cron) continue;
|
|
275
|
+
try {
|
|
276
|
+
const job = (
|
|
277
|
+
Bun.cron as (
|
|
278
|
+
schedule: string,
|
|
279
|
+
handler: () => unknown,
|
|
280
|
+
options?: { readonly tz?: string },
|
|
281
|
+
) => { stop(): void; unref(): void }
|
|
282
|
+
)(
|
|
283
|
+
decl.cron,
|
|
284
|
+
() => {
|
|
285
|
+
void runtime.tick();
|
|
286
|
+
},
|
|
287
|
+
{ tz: decl.timezone },
|
|
288
|
+
);
|
|
289
|
+
job.unref();
|
|
290
|
+
wakes.push(job);
|
|
291
|
+
} catch {
|
|
292
|
+
/* invalid expression — 1s tick remains the fallback */
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
},
|
|
296
|
+
stopWakes() {
|
|
297
|
+
for (const job of wakes) {
|
|
298
|
+
try {
|
|
299
|
+
job.stop();
|
|
300
|
+
} catch {
|
|
301
|
+
/* already stopped */
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
wakes.length = 0;
|
|
305
|
+
},
|
|
253
306
|
};
|
|
254
307
|
|
|
255
308
|
return runtime;
|
|
@@ -1,13 +1,58 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Next-occurrence helpers for cron / every schedules (console §9.6 timeline).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Cron math uses {@link Bun.cron.parse} (5-field + nicknames + `{ tz }`).
|
|
5
|
+
* Interval (`every`) stays on {@link parseDurationMs}.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { parseDurationMs } from "./duration.ts";
|
|
9
9
|
import { effectiveSchedule, type CronRow } from "./reconcile.ts";
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Bun 1.4 `{ tz }` parse — `@types/bun` 1.3.14 only declares two arguments.
|
|
13
|
+
*
|
|
14
|
+
* @param expression - 5-field expression or nickname
|
|
15
|
+
* @param relativeMs - Exclusive start
|
|
16
|
+
* @param timezone - IANA zone
|
|
17
|
+
*/
|
|
18
|
+
function parseCronNext(expression: string, relativeMs: number, timezone: string): Date | null {
|
|
19
|
+
return (
|
|
20
|
+
Bun.cron.parse as (
|
|
21
|
+
expression: string,
|
|
22
|
+
relativeDate?: Date | number,
|
|
23
|
+
options?: { readonly tz?: string },
|
|
24
|
+
) => Date | null
|
|
25
|
+
)(expression, relativeMs, { tz: timezone });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Cap walk loops so a `* * * * *` range cannot hang the Console timeline. */
|
|
29
|
+
const MAX_CRON_FIRES = 10_000;
|
|
30
|
+
/** How far back {@link previousOccurrence} searches for a prior cron fire. */
|
|
31
|
+
const PREVIOUS_LOOKBACK_MS = 400 * 86_400_000;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Next matching UTC epoch-ms after `relativeMs`, or `undefined` when the
|
|
35
|
+
* expression is invalid or has no match within eight years.
|
|
36
|
+
*
|
|
37
|
+
* {@link Bun.cron.parse} is exclusive of `relativeMs`.
|
|
38
|
+
*
|
|
39
|
+
* @param cron - 5-field expression or nickname (`@hourly`)
|
|
40
|
+
* @param relativeMs - Search starts after this instant
|
|
41
|
+
* @param timezone - IANA zone passed as `{ tz }`
|
|
42
|
+
*/
|
|
43
|
+
export function nextCronFireAt(
|
|
44
|
+
cron: string,
|
|
45
|
+
relativeMs: number,
|
|
46
|
+
timezone: string,
|
|
47
|
+
): number | undefined {
|
|
48
|
+
try {
|
|
49
|
+
const next = parseCronNext(cron, relativeMs, timezone);
|
|
50
|
+
return next === null ? undefined : next.getTime();
|
|
51
|
+
} catch {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
11
56
|
/**
|
|
12
57
|
* Compute fire times in `[from, until)` for a reconciled cron row.
|
|
13
58
|
*
|
|
@@ -24,7 +69,7 @@ export function nextOccurrences(row: CronRow, from: number, until: number): read
|
|
|
24
69
|
return nextEveryOccurrences(row, sched.every, from, until);
|
|
25
70
|
}
|
|
26
71
|
if (sched.cron) {
|
|
27
|
-
return nextCronOccurrences(
|
|
72
|
+
return nextCronOccurrences(sched.cron, row.timezone, from, until);
|
|
28
73
|
}
|
|
29
74
|
return [];
|
|
30
75
|
}
|
|
@@ -41,25 +86,12 @@ export function previousOccurrence(row: CronRow, at: number): number | null {
|
|
|
41
86
|
const interval = parseDurationMs(sched.every);
|
|
42
87
|
if (interval <= 0) return null;
|
|
43
88
|
if (row.lastRunAt === undefined) return null;
|
|
44
|
-
// Floor to the slot boundary at or before `at`.
|
|
45
89
|
const origin = row.lastRunAt % interval;
|
|
46
90
|
const slot = at - ((at - origin) % interval);
|
|
47
91
|
return slot > 0 ? slot : null;
|
|
48
92
|
}
|
|
49
93
|
if (sched.cron) {
|
|
50
|
-
|
|
51
|
-
if (!parsed) return null;
|
|
52
|
-
// Walk back up to 400 days for a prior civil fire.
|
|
53
|
-
for (let day = 1; day <= 400; day++) {
|
|
54
|
-
const probe = at - day * 86_400_000;
|
|
55
|
-
const local = zonedParts(probe, sched.timezone);
|
|
56
|
-
if (local.hour === parsed.hour && local.minute === parsed.minute) {
|
|
57
|
-
// Align to that local wall time's UTC instant near `probe`.
|
|
58
|
-
const fires = civilFiresOnDay(local.date, parsed, sched.timezone);
|
|
59
|
-
const prior = fires.filter((t) => t <= at).pop();
|
|
60
|
-
if (prior !== undefined) return prior;
|
|
61
|
-
}
|
|
62
|
-
}
|
|
94
|
+
return previousCronFire(sched.cron, row.timezone, at);
|
|
63
95
|
}
|
|
64
96
|
return null;
|
|
65
97
|
}
|
|
@@ -77,11 +109,10 @@ export function countMissedOccurrences(row: CronRow, from: number, until: number
|
|
|
77
109
|
if (sched.every) {
|
|
78
110
|
const interval = parseDurationMs(sched.every);
|
|
79
111
|
if (interval <= 0) return 0;
|
|
80
|
-
return Math.max(0, Math.floor((until - from) / interval)
|
|
112
|
+
return Math.max(0, Math.floor((until - from) / interval));
|
|
81
113
|
}
|
|
82
114
|
if (sched.cron) {
|
|
83
|
-
|
|
84
|
-
const fires = nextCronOccurrences(row, sched.cron, sched.timezone, from + 1, until + 1);
|
|
115
|
+
const fires = nextCronOccurrences(sched.cron, row.timezone, from + 1, until + 1);
|
|
85
116
|
return fires.filter((t) => t > from && t <= until).length;
|
|
86
117
|
}
|
|
87
118
|
return 0;
|
|
@@ -102,145 +133,43 @@ function nextEveryOccurrences(
|
|
|
102
133
|
: row.lastRunAt !== undefined
|
|
103
134
|
? row.lastRunAt + interval
|
|
104
135
|
: from;
|
|
105
|
-
// If still in the past relative to `from`, advance to first >= from.
|
|
106
136
|
while (t < from) t += interval;
|
|
107
137
|
while (t < until) {
|
|
108
138
|
out.push(t);
|
|
109
139
|
t += interval;
|
|
110
|
-
if (out.length >
|
|
140
|
+
if (out.length > MAX_CRON_FIRES) break;
|
|
111
141
|
}
|
|
112
142
|
return out;
|
|
113
143
|
}
|
|
114
144
|
|
|
115
145
|
function nextCronOccurrences(
|
|
116
|
-
row: CronRow,
|
|
117
146
|
cron: string,
|
|
118
147
|
timezone: string,
|
|
119
148
|
from: number,
|
|
120
149
|
until: number,
|
|
121
150
|
): readonly number[] {
|
|
122
|
-
const parsed = parseSimpleDaily(cron);
|
|
123
|
-
if (!parsed) {
|
|
124
|
-
if (row.nextRunAt !== undefined && row.nextRunAt >= from && row.nextRunAt < until) {
|
|
125
|
-
return [row.nextRunAt];
|
|
126
|
-
}
|
|
127
|
-
return [];
|
|
128
|
-
}
|
|
129
|
-
|
|
130
151
|
const out: number[] = [];
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
0,
|
|
139
|
-
0,
|
|
140
|
-
);
|
|
141
|
-
if (probe > until + 86_400_000) break;
|
|
142
|
-
const local = zonedParts(probe, timezone);
|
|
143
|
-
const fires = civilFiresOnDay(local.date, parsed, timezone);
|
|
144
|
-
for (const t of fires) {
|
|
145
|
-
if (t >= from && t < until) out.push(t);
|
|
146
|
-
}
|
|
147
|
-
if (out.length > 10_000) break;
|
|
152
|
+
// parse is exclusive of the cursor — step back 1ms so `from` itself can match.
|
|
153
|
+
let cursor = from - 1;
|
|
154
|
+
while (out.length < MAX_CRON_FIRES) {
|
|
155
|
+
const t = nextCronFireAt(cron, cursor, timezone);
|
|
156
|
+
if (t === undefined || t >= until) break;
|
|
157
|
+
if (t >= from) out.push(t);
|
|
158
|
+
cursor = t;
|
|
148
159
|
}
|
|
149
|
-
return out
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/** Parsed simple daily cron. */
|
|
153
|
-
interface SimpleDaily {
|
|
154
|
-
readonly minute: number;
|
|
155
|
-
readonly hour: number;
|
|
156
|
-
readonly dow?: number;
|
|
160
|
+
return out;
|
|
157
161
|
}
|
|
158
162
|
|
|
159
|
-
function
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
const m = Number(minute);
|
|
170
|
-
const h = Number(hour);
|
|
171
|
-
if (m < 0 || m > 59 || h < 0 || h > 23) return null;
|
|
172
|
-
if (dow === "*") return { minute: m, hour: h };
|
|
173
|
-
if (/^\d+$/.test(dow)) {
|
|
174
|
-
const d = Number(dow);
|
|
175
|
-
if (d < 0 || d > 6) return null;
|
|
176
|
-
return { minute: m, hour: h, dow: d };
|
|
163
|
+
function previousCronFire(cron: string, timezone: string, at: number): number | null {
|
|
164
|
+
let cursor = at - PREVIOUS_LOOKBACK_MS;
|
|
165
|
+
let prev: number | null = null;
|
|
166
|
+
let steps = 0;
|
|
167
|
+
while (steps < MAX_CRON_FIRES) {
|
|
168
|
+
const t = nextCronFireAt(cron, cursor, timezone);
|
|
169
|
+
if (t === undefined || t >= at) break;
|
|
170
|
+
prev = t;
|
|
171
|
+
cursor = t;
|
|
172
|
+
steps++;
|
|
177
173
|
}
|
|
178
|
-
return
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
function civilFiresOnDay(
|
|
182
|
-
dateStr: string,
|
|
183
|
-
parsed: SimpleDaily,
|
|
184
|
-
timezone: string,
|
|
185
|
-
): readonly number[] {
|
|
186
|
-
const matches: number[] = [];
|
|
187
|
-
const base = Date.parse(`${dateStr}T00:00:00.000Z`);
|
|
188
|
-
if (Number.isNaN(base)) return [];
|
|
189
|
-
|
|
190
|
-
for (let offsetMin = -14 * 60; offsetMin <= 14 * 60; offsetMin += 15) {
|
|
191
|
-
const utc = base + parsed.hour * 3_600_000 + parsed.minute * 60_000 - offsetMin * 60_000;
|
|
192
|
-
const local = zonedParts(utc, timezone);
|
|
193
|
-
if (local.date === dateStr && local.hour === parsed.hour && local.minute === parsed.minute) {
|
|
194
|
-
if (parsed.dow !== undefined) {
|
|
195
|
-
const localDow = zonedDow(utc, timezone);
|
|
196
|
-
if (localDow !== parsed.dow) continue;
|
|
197
|
-
}
|
|
198
|
-
if (!matches.includes(utc)) matches.push(utc);
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
return matches;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
interface ZonedParts {
|
|
205
|
-
readonly date: string;
|
|
206
|
-
readonly hour: number;
|
|
207
|
-
readonly minute: number;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
function zonedParts(utcMs: number, timezone: string): ZonedParts {
|
|
211
|
-
const fmt = new Intl.DateTimeFormat("en-US", {
|
|
212
|
-
timeZone: timezone,
|
|
213
|
-
year: "numeric",
|
|
214
|
-
month: "2-digit",
|
|
215
|
-
day: "2-digit",
|
|
216
|
-
hour: "2-digit",
|
|
217
|
-
minute: "2-digit",
|
|
218
|
-
hourCycle: "h23",
|
|
219
|
-
});
|
|
220
|
-
const parts = fmt.formatToParts(new Date(utcMs));
|
|
221
|
-
const get = (type: Intl.DateTimeFormatPartTypes): string =>
|
|
222
|
-
parts.find((p) => p.type === type)?.value ?? "0";
|
|
223
|
-
return {
|
|
224
|
-
date: `${get("year")}-${get("month")}-${get("day")}`,
|
|
225
|
-
hour: Number(get("hour")),
|
|
226
|
-
minute: Number(get("minute")),
|
|
227
|
-
};
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
function zonedDow(utcMs: number, timezone: string): number {
|
|
231
|
-
const fmt = new Intl.DateTimeFormat("en-US", {
|
|
232
|
-
timeZone: timezone,
|
|
233
|
-
weekday: "short",
|
|
234
|
-
});
|
|
235
|
-
const w = fmt.format(new Date(utcMs));
|
|
236
|
-
const map: Record<string, number> = {
|
|
237
|
-
Sun: 0,
|
|
238
|
-
Mon: 1,
|
|
239
|
-
Tue: 2,
|
|
240
|
-
Wed: 3,
|
|
241
|
-
Thu: 4,
|
|
242
|
-
Fri: 5,
|
|
243
|
-
Sat: 6,
|
|
244
|
-
};
|
|
245
|
-
return map[w] ?? 0;
|
|
174
|
+
return prev;
|
|
246
175
|
}
|
|
@@ -466,8 +466,9 @@ describe("DST ambiguity", () => {
|
|
|
466
466
|
expect(doctorSrc).not.toMatch(/detectDstAmbiguity/);
|
|
467
467
|
});
|
|
468
468
|
|
|
469
|
-
test("overlap day:
|
|
470
|
-
// US fall-back 2026-11-01: 01:30 local occurs twice
|
|
469
|
+
test("overlap day: crontab fires once; next slot is the following day", async () => {
|
|
470
|
+
// US fall-back 2026-11-01: 01:30 local occurs twice. Bun.cron.parse
|
|
471
|
+
// matches crontab — a fixed wall time fires at the first occurrence only.
|
|
471
472
|
const from = Date.UTC(2026, 10, 1, 0, 0, 0);
|
|
472
473
|
const until = Date.UTC(2026, 10, 2, 0, 0, 0);
|
|
473
474
|
const fires = nextOccurrences(
|
|
@@ -481,13 +482,11 @@ describe("DST ambiguity", () => {
|
|
|
481
482
|
from,
|
|
482
483
|
until,
|
|
483
484
|
);
|
|
484
|
-
expect(fires
|
|
485
|
-
expect(fires[
|
|
485
|
+
expect(fires).toHaveLength(1);
|
|
486
|
+
expect(fires[0]).toBe(Date.UTC(2026, 10, 1, 5, 30, 0));
|
|
486
487
|
|
|
487
|
-
// Stub isDue (nextRunAt unset after first fire): does not walk civil fires →
|
|
488
|
-
// advancing across the overlap gap does not double-fire.
|
|
489
488
|
const store = createMemoryCronStore();
|
|
490
|
-
const rt = createTestClockRuntime(0, {
|
|
489
|
+
const rt = createTestClockRuntime(fires[0]! - 1, {
|
|
491
490
|
store,
|
|
492
491
|
instanceId: "dst-fire",
|
|
493
492
|
leaseMs: 1_000,
|
|
@@ -503,43 +502,13 @@ describe("DST ambiguity", () => {
|
|
|
503
502
|
rt.onCron("ambig", () => {
|
|
504
503
|
ran.push(rt.now());
|
|
505
504
|
});
|
|
505
|
+
rt.advance(2);
|
|
506
506
|
expect((await rt.tick()).ran).toEqual(["ambig"]);
|
|
507
507
|
rt.advance(3_600_000);
|
|
508
508
|
expect((await rt.tick()).ran).toEqual([]);
|
|
509
509
|
expect(ran).toHaveLength(1);
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
// instant, a second fire CAN occur. Leader lease is not a DST policy.
|
|
513
|
-
const store2 = createMemoryCronStore();
|
|
514
|
-
const rt2 = createTestClockRuntime(fires[0]! - 1, {
|
|
515
|
-
store: store2,
|
|
516
|
-
instanceId: "dst-risk",
|
|
517
|
-
leaseMs: 1_000,
|
|
518
|
-
});
|
|
519
|
-
rt2.register(
|
|
520
|
-
clock("ambig2", {
|
|
521
|
-
cron: "30 1 * * *",
|
|
522
|
-
timezone: "America/New_York",
|
|
523
|
-
}),
|
|
524
|
-
);
|
|
525
|
-
await rt2.reconcile();
|
|
526
|
-
await store2.put({
|
|
527
|
-
...(await store2.get("ambig2"))!,
|
|
528
|
-
nextRunAt: fires[0],
|
|
529
|
-
});
|
|
530
|
-
const ran2: number[] = [];
|
|
531
|
-
rt2.onCron("ambig2", () => {
|
|
532
|
-
ran2.push(rt2.now());
|
|
533
|
-
});
|
|
534
|
-
rt2.advance(2);
|
|
535
|
-
expect((await rt2.tick()).ran).toEqual(["ambig2"]);
|
|
536
|
-
rt2.advance(fires[1]! - rt2.now());
|
|
537
|
-
await store2.put({
|
|
538
|
-
...(await store2.get("ambig2"))!,
|
|
539
|
-
nextRunAt: fires[1],
|
|
540
|
-
});
|
|
541
|
-
expect((await rt2.tick()).ran).toEqual(["ambig2"]);
|
|
542
|
-
expect(ran2).toHaveLength(2);
|
|
510
|
+
const after = await store.get("ambig");
|
|
511
|
+
expect(after?.nextRunAt).toBeGreaterThan(fires[0]! + 3_600_000);
|
|
543
512
|
});
|
|
544
513
|
});
|
|
545
514
|
|
package/src/elements/clock.ts
CHANGED
|
@@ -50,7 +50,12 @@ export { runDurable, type DurableResult, type RunDurableOptions } from "./clock/
|
|
|
50
50
|
|
|
51
51
|
export { cronHealth, type CatchUpPolicy, type CronHealth } from "./clock/health.ts";
|
|
52
52
|
|
|
53
|
-
export {
|
|
53
|
+
export {
|
|
54
|
+
nextOccurrences,
|
|
55
|
+
previousOccurrence,
|
|
56
|
+
countMissedOccurrences,
|
|
57
|
+
nextCronFireAt,
|
|
58
|
+
} from "./clock/schedule.ts";
|
|
54
59
|
|
|
55
60
|
export {
|
|
56
61
|
pauseCron,
|