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.
- package/AGENTS.md +2 -2
- package/README.md +73 -181
- package/package.json +3 -3
- 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 +42 -25
- package/site/content/docs/elements/clock.mdx +59 -25
- package/site/content/docs/elements/flow.mdx +8 -2
- 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 +595 -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 +52 -14
- package/site/content/docs/get-started/introduction.mdx +1 -1
- package/site/content/docs/plugins/magic-link.mdx +42 -0
- package/site/content/docs/plugins/phone-number.mdx +78 -17
- package/site/content/docs/plugins/two-factor.mdx +1 -0
- package/site/content/docs/reference/cli.md +32 -2
- package/site/content/docs/reference/configuration.mdx +15 -13
- package/site/content/docs/reference/environment-variables.mdx +32 -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/db-seed.ts +359 -0
- package/src/cli/db.test.ts +341 -3
- package/src/cli/db.ts +75 -8
- 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.images.test.ts +22 -0
- package/src/cli/load-config.ts +7 -2
- package/src/cli/openbao-bootstrap.test.ts +1 -0
- package/src/cli/openbao-bootstrap.ts +9 -2
- package/src/cli/registry.ts +116 -2
- 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/effects-infer.ts +1 -0
- package/src/compiler/fixtures/skyport/oke.config.ts +1 -1
- package/src/config/define-config.test.ts +6 -6
- package/src/config/index.ts +5 -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/index.ts +19 -0
- package/src/docker/recipes/ollama.ts +10 -5
- package/src/drivers/ai-ollama.ts +45 -2
- package/src/drivers/ai-providers.test.ts +31 -0
- package/src/drivers/channel-sently.test.ts +8 -0
- package/src/drivers/channel-taqnyat-mail.ts +34 -0
- package/src/drivers/channel-types.ts +71 -0
- package/src/drivers/clock-postgres.test.ts +258 -0
- package/src/drivers/clock-postgres.ts +410 -0
- package/src/drivers/index.ts +21 -0
- package/src/drivers/journal-postgres.test.ts +175 -0
- package/src/drivers/journal-postgres.ts +492 -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/runtime.ts +51 -0
- package/src/elements/channel.test.ts +71 -0
- package/src/elements/clock/chaos-child.ts +389 -0
- package/src/elements/clock/chaos.test.ts +204 -0
- package/src/elements/clock/durable.ts +7 -0
- package/src/elements/clock/reconcile.ts +110 -0
- package/src/elements/clock/runtime.ts +13 -0
- package/src/elements/clock.test.ts +164 -2
- package/src/elements/clock.ts +2 -1
- package/src/elements/gate/runtime.ts +6 -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/seed.test.ts +27 -0
- package/src/elements/store/seed.ts +68 -0
- package/src/elements/store/sql-session.test.ts +39 -0
- package/src/elements/store/sql-session.ts +55 -0
- package/src/elements/store/upsert-app.test.ts +103 -0
- package/src/elements/store.ts +29 -0
- package/src/elements/vault/boot-chain.ts +150 -0
- package/src/elements/vault/runtime.ts +8 -0
- package/src/index.ts +15 -0
- package/src/kernel/app.ts +168 -15
- package/src/kernel/boot-bind/channel.test.ts +16 -0
- package/src/kernel/boot-bind/channel.ts +13 -0
- package/src/kernel/boot-bind/clock.ts +63 -7
- package/src/kernel/boot-bind/gate.ts +71 -3
- package/src/kernel/boot-bind/honor-config.test.ts +348 -0
- package/src/kernel/boot-bind/journal.ts +89 -0
- package/src/kernel/boot-bind/signal.ts +64 -7
- package/src/kernel/boot-bind/vault.ts +27 -8
- package/src/kernel/boot.test.ts +6 -4
- package/src/kernel/boot.ts +63 -15
- package/src/kernel/concurrency.ts +1 -1
- package/src/kernel/errors.ts +8 -2
- package/src/kernel/fx.test.ts +19 -0
- package/src/kernel/fx.ts +153 -9
- package/src/kernel/index.ts +6 -0
- package/src/kernel/journal-boot.test.ts +397 -0
- package/src/kernel/journal-suspend.ts +35 -0
- package/src/kernel/journal.test.ts +142 -0
- package/src/kernel/journal.ts +202 -27
- package/src/plugins/auth-methods.security.test.ts +10 -7
- package/src/plugins/phone-number.ts +67 -10
- package/src/plugins/taqnyat.live.test.ts +174 -0
- 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
package/src/kernel/app.ts
CHANGED
|
@@ -55,6 +55,7 @@ import {
|
|
|
55
55
|
} from "./fx.ts";
|
|
56
56
|
import {
|
|
57
57
|
currentDevSurface,
|
|
58
|
+
failureDetailFromResponse,
|
|
58
59
|
logDevRequest,
|
|
59
60
|
shouldLogDevRequests,
|
|
60
61
|
} from "../runtime/dev-request-log.ts";
|
|
@@ -70,9 +71,14 @@ import {
|
|
|
70
71
|
import {
|
|
71
72
|
createJournal,
|
|
72
73
|
createMemoryJournalStore,
|
|
74
|
+
hasJournalLease,
|
|
75
|
+
isJournalLeaseBusy,
|
|
73
76
|
isJournalSuspend,
|
|
77
|
+
JOURNAL_DEFAULT_LEASE_MS,
|
|
74
78
|
type JournalSession,
|
|
79
|
+
type JournalStore,
|
|
75
80
|
} from "./journal.ts";
|
|
81
|
+
import type { JournalRuntime } from "./boot-bind/journal.ts";
|
|
76
82
|
import { listBindings, resetBindings, type Binding } from "./on.ts";
|
|
77
83
|
import {
|
|
78
84
|
applyPrincipal,
|
|
@@ -189,6 +195,8 @@ export interface OkeOptions {
|
|
|
189
195
|
readonly startScheduler?: boolean;
|
|
190
196
|
/** Scheduler tick period ms (default 1000). Forwarded to boot. */
|
|
191
197
|
readonly schedulerIntervalMs?: number;
|
|
198
|
+
/** Durable-run lease duration ms (default 30_000). Forwarded to boot. */
|
|
199
|
+
readonly journalLeaseMs?: number;
|
|
192
200
|
}
|
|
193
201
|
|
|
194
202
|
/** Payload for a CDC invocation. */
|
|
@@ -578,13 +586,37 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
578
586
|
} else {
|
|
579
587
|
setActiveGateAuthContext(undefined);
|
|
580
588
|
}
|
|
581
|
-
|
|
589
|
+
// Fallback journal for pre-boot / `autoBoot: false` unit tests. A booted
|
|
590
|
+
// app replaces this with the bound `drivers.journal` store (postgres etc.).
|
|
591
|
+
const fallbackJournalStore = createMemoryJournalStore();
|
|
592
|
+
const fallbackJournalInstanceId = `app-${crypto.randomUUID()}`;
|
|
582
593
|
const sleepingRuns = new Map<
|
|
583
594
|
string,
|
|
584
595
|
{ readonly flow: AnyFlowDef; readonly input: unknown; readonly wakeAt: number }
|
|
585
596
|
>();
|
|
597
|
+
// Same-process mutual exclusion per runId. The lease coordinates across
|
|
598
|
+
// processes, but same-holder renew cannot distinguish two overlapping
|
|
599
|
+
// sessions inside one process (boot orphan scan vs. scheduler tick).
|
|
600
|
+
const inflightRuns = new Set<string>();
|
|
586
601
|
const EMPTY_CAPABILITIES: ReadonlyMap<string, CapabilityToken> = new Map();
|
|
587
602
|
|
|
603
|
+
/** Active journal: the boot-bound store once available, else the fallback. */
|
|
604
|
+
function activeJournal(): {
|
|
605
|
+
readonly store: JournalStore;
|
|
606
|
+
readonly instanceId: string;
|
|
607
|
+
readonly leaseMs: number;
|
|
608
|
+
} {
|
|
609
|
+
const bound: JournalRuntime | undefined = bootResult?.journal;
|
|
610
|
+
if (bound) {
|
|
611
|
+
return { store: bound.store, instanceId: bound.instanceId, leaseMs: bound.leaseMs };
|
|
612
|
+
}
|
|
613
|
+
return {
|
|
614
|
+
store: fallbackJournalStore,
|
|
615
|
+
instanceId: fallbackJournalInstanceId,
|
|
616
|
+
leaseMs: JOURNAL_DEFAULT_LEASE_MS,
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
|
|
588
620
|
async function handleCronFire(name: string): Promise<void> {
|
|
589
621
|
await app.dispatchEvery(name);
|
|
590
622
|
// Named clocks (e.g. `clock("expire-stale", { every: "1h" })`) reconcile
|
|
@@ -599,6 +631,84 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
599
631
|
await app.dispatchSignal(name, payload);
|
|
600
632
|
}
|
|
601
633
|
|
|
634
|
+
async function handleDurableResume(): Promise<void> {
|
|
635
|
+
await app.resumeDurable();
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/** Element runtimes handed to durable resumes (same bag as cron dispatch). */
|
|
639
|
+
function durableResumeFx() {
|
|
640
|
+
return {
|
|
641
|
+
storeRuntime: bootResult?.store,
|
|
642
|
+
signalRuntime: bootResult?.signal,
|
|
643
|
+
vaultRuntime: bootResult?.vault,
|
|
644
|
+
channelRuntime: bootResult?.channel,
|
|
645
|
+
aiRuntime: bootResult?.ai,
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* Resume one persisted run under its lease. A lost lease race is the
|
|
651
|
+
* coordination win — another live instance owns the run, so skip quietly.
|
|
652
|
+
*/
|
|
653
|
+
/** Runs whose undeclared flow was already logged (sweep ticks must not spam). */
|
|
654
|
+
const warnedOrphanRuns = new Set<string>();
|
|
655
|
+
|
|
656
|
+
async function resumeDurableRun(
|
|
657
|
+
runId: string,
|
|
658
|
+
flowName: string,
|
|
659
|
+
input: unknown,
|
|
660
|
+
now: () => number,
|
|
661
|
+
): Promise<void> {
|
|
662
|
+
// The lease only coordinates across processes — within one process,
|
|
663
|
+
// overlapping callers (orphan scan vs. scheduler tick) would both pass
|
|
664
|
+
// same-holder lease renewal and run two sessions for one runId.
|
|
665
|
+
if (inflightRuns.has(runId)) return;
|
|
666
|
+
inflightRuns.add(runId);
|
|
667
|
+
try {
|
|
668
|
+
const flowDef = flowsByName.get(flowName);
|
|
669
|
+
if (!flowDef) {
|
|
670
|
+
if (!warnedOrphanRuns.has(runId)) {
|
|
671
|
+
warnedOrphanRuns.add(runId);
|
|
672
|
+
console.warn(`oke: durable run "${runId}": undeclared flow "${flowName}" — skipped`);
|
|
673
|
+
}
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
const { store, instanceId, leaseMs } = activeJournal();
|
|
677
|
+
try {
|
|
678
|
+
await runDurable({
|
|
679
|
+
flow: flowDef,
|
|
680
|
+
input,
|
|
681
|
+
journalStore: store,
|
|
682
|
+
runId,
|
|
683
|
+
...(hasJournalLease(store) ? { lease: { instanceId, leaseMs } } : {}),
|
|
684
|
+
now,
|
|
685
|
+
fx: durableResumeFx(),
|
|
686
|
+
});
|
|
687
|
+
} catch (err) {
|
|
688
|
+
if (isJournalLeaseBusy(err)) return;
|
|
689
|
+
throw err;
|
|
690
|
+
}
|
|
691
|
+
} finally {
|
|
692
|
+
inflightRuns.delete(runId);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/** Boot-time orphan scan — `running`/`sleeping` runs with no live lease. */
|
|
697
|
+
async function resumeOrphanedDurableRuns(): Promise<void> {
|
|
698
|
+
const { store } = activeJournal();
|
|
699
|
+
if (!hasJournalLease(store)) return;
|
|
700
|
+
const now = () => bootResult?.clock?.now() ?? options.fx?.now?.() ?? Date.now();
|
|
701
|
+
const orphans = await store.listOrphans(now());
|
|
702
|
+
for (const orphan of orphans) {
|
|
703
|
+
// Future sleeps are claimed by the scheduler tick when they come due —
|
|
704
|
+
// the shared store is the schedule, no in-process seeding needed.
|
|
705
|
+
if (orphan.status === "sleeping" && orphan.wakeAt !== undefined && orphan.wakeAt > now()) {
|
|
706
|
+
continue;
|
|
707
|
+
}
|
|
708
|
+
await resumeDurableRun(orphan.id, orphan.flow, orphan.input, now);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
602
712
|
async function doBoot(overrides?: Partial<BootOptions>): Promise<BootResult> {
|
|
603
713
|
const { bootApplication, resolveElementNeeds } = await import("./boot.ts");
|
|
604
714
|
const { assertHttpGatePosture } = await import("../elements/gate/boot.ts");
|
|
@@ -715,13 +825,23 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
715
825
|
instanceId: overrides?.instanceId,
|
|
716
826
|
startScheduler: overrides?.startScheduler ?? options.startScheduler,
|
|
717
827
|
schedulerIntervalMs: overrides?.schedulerIntervalMs ?? options.schedulerIntervalMs,
|
|
828
|
+
journalLeaseMs: overrides?.journalLeaseMs ?? options.journalLeaseMs,
|
|
718
829
|
bindings: adopted,
|
|
719
830
|
flows: [...flowsByName.values()],
|
|
720
831
|
onCronFire: overrides?.onCronFire ?? handleCronFire,
|
|
721
832
|
onSignal: overrides?.onSignal ?? handleSignalFire,
|
|
833
|
+
onDurableResume: overrides?.onDurableResume ?? handleDurableResume,
|
|
722
834
|
};
|
|
723
835
|
const result = await bootApplication(merged);
|
|
724
836
|
bootResult = result;
|
|
837
|
+
// Boot-time orphan discovery: resume/schedule any `running` / `sleeping`
|
|
838
|
+
// run left without a live lease by a crashed (or previous) instance.
|
|
839
|
+
// Fire-and-forget — boot must not block serving on a long resume.
|
|
840
|
+
if (result.journal && hasJournalLease(result.journal.store)) {
|
|
841
|
+
void resumeOrphanedDurableRuns().catch((err) => {
|
|
842
|
+
console.error("oke: durable orphan scan failed", err);
|
|
843
|
+
});
|
|
844
|
+
}
|
|
725
845
|
// Prefer the booted clock for access-token expiry checks.
|
|
726
846
|
if (authBinding) {
|
|
727
847
|
authBinding = createAppAuthBinding({
|
|
@@ -907,8 +1027,16 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
907
1027
|
capability = booted.capabilities.get(flowDef.name);
|
|
908
1028
|
|
|
909
1029
|
if (flowDef.durable) {
|
|
910
|
-
const
|
|
1030
|
+
const { store, instanceId, leaseMs } = activeJournal();
|
|
1031
|
+
const journal = createJournal({
|
|
1032
|
+
store,
|
|
1033
|
+
now,
|
|
1034
|
+
// Hold the run lease for the request's lifetime — a crash mid-run
|
|
1035
|
+
// leaves an expired lease another instance can reclaim and resume.
|
|
1036
|
+
...(hasJournalLease(store) ? { lease: { instanceId, leaseMs } } : {}),
|
|
1037
|
+
});
|
|
911
1038
|
journalSession = await journal.start(flowDef.name, input);
|
|
1039
|
+
inflightRuns.add(journalSession.runId);
|
|
912
1040
|
}
|
|
913
1041
|
}
|
|
914
1042
|
|
|
@@ -997,25 +1125,34 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
997
1125
|
// HTTP flows serialize before `onResponse` so the last stage can see
|
|
998
1126
|
// and replace the final response (plugin header/middleware surfaces).
|
|
999
1127
|
trigger.kind === "http" ? encodeExecuteResult : undefined,
|
|
1000
|
-
)
|
|
1128
|
+
).catch((err: unknown) => {
|
|
1129
|
+
// Park suspensions are already absorbed above; a real pipeline failure
|
|
1130
|
+
// still leaves the run lease to expire for cross-process reclaim, but
|
|
1131
|
+
// must not pin the runId in this process's in-flight guard forever.
|
|
1132
|
+
if (journalSession) inflightRuns.delete(journalSession.runId);
|
|
1133
|
+
throw err;
|
|
1134
|
+
});
|
|
1001
1135
|
|
|
1002
1136
|
const endedAt = now();
|
|
1003
1137
|
|
|
1004
1138
|
if (journalSession) {
|
|
1139
|
+
inflightRuns.delete(journalSession.runId);
|
|
1005
1140
|
const sleeping = ctx.state.sleeping as
|
|
1006
1141
|
| { readonly wakeAt: number; readonly label: string; readonly runId: string }
|
|
1007
1142
|
| undefined;
|
|
1008
|
-
|
|
1143
|
+
// Lease-capable stores make the shared row the wake schedule; the
|
|
1144
|
+
// in-process map is only for custom stores without the lease surface.
|
|
1145
|
+
if (sleeping && !hasJournalLease(activeJournal().store)) {
|
|
1009
1146
|
sleepingRuns.set(sleeping.runId, {
|
|
1010
1147
|
flow: flowDef,
|
|
1011
1148
|
input: ctx.input,
|
|
1012
1149
|
wakeAt: sleeping.wakeAt,
|
|
1013
1150
|
});
|
|
1014
|
-
} else if (result.failure) {
|
|
1151
|
+
} else if (!sleeping && result.failure) {
|
|
1015
1152
|
await journalSession.commit("failed", {
|
|
1016
1153
|
error: result.failure.error.code,
|
|
1017
1154
|
});
|
|
1018
|
-
} else {
|
|
1155
|
+
} else if (!sleeping) {
|
|
1019
1156
|
await journalSession.commit("completed", { output: result.output });
|
|
1020
1157
|
}
|
|
1021
1158
|
}
|
|
@@ -1073,6 +1210,7 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
1073
1210
|
channel: bootResult.channel,
|
|
1074
1211
|
ai: bootResult.ai,
|
|
1075
1212
|
runs: bootResult.runs,
|
|
1213
|
+
...(bootResult.journal ? { journal: bootResult.journal } : {}),
|
|
1076
1214
|
};
|
|
1077
1215
|
},
|
|
1078
1216
|
get capabilities() {
|
|
@@ -1098,22 +1236,36 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
1098
1236
|
},
|
|
1099
1237
|
async resumeDurable(now) {
|
|
1100
1238
|
const t = now ?? bootResult?.clock?.now() ?? options.fx?.now?.() ?? Date.now();
|
|
1239
|
+
const { store, instanceId, leaseMs } = activeJournal();
|
|
1240
|
+
if (hasJournalLease(store)) {
|
|
1241
|
+
// Shared store is the wake schedule — claim due sleeps across all
|
|
1242
|
+
// instances (SKIP LOCKED; exactly one claimant wins a raced claim).
|
|
1243
|
+
for (;;) {
|
|
1244
|
+
const due = await store.claimDueSleep(instanceId, t, leaseMs);
|
|
1245
|
+
if (!due) break;
|
|
1246
|
+
sleepingRuns.delete(due.id);
|
|
1247
|
+
await resumeDurableRun(due.id, due.flow, due.input, () => t);
|
|
1248
|
+
}
|
|
1249
|
+
// Crash sweep: the boot orphan scan is once-only, so each tick also
|
|
1250
|
+
// reclaims `running` runs whose holder's lease has expired (same
|
|
1251
|
+
// takeover physics as Clock: lease expiry + next tick).
|
|
1252
|
+
for (const orphan of await store.listOrphans(t)) {
|
|
1253
|
+
if (orphan.status !== "running") continue;
|
|
1254
|
+
await resumeDurableRun(orphan.id, orphan.flow, orphan.input, () => t);
|
|
1255
|
+
}
|
|
1256
|
+
return;
|
|
1257
|
+
}
|
|
1258
|
+
// Custom store without the lease surface — in-process sleepers only.
|
|
1101
1259
|
for (const [runId, sleeper] of [...sleepingRuns.entries()]) {
|
|
1102
1260
|
if (t < sleeper.wakeAt) continue;
|
|
1103
1261
|
sleepingRuns.delete(runId);
|
|
1104
1262
|
const result = await runDurable({
|
|
1105
1263
|
flow: sleeper.flow,
|
|
1106
1264
|
input: sleeper.input,
|
|
1107
|
-
journalStore,
|
|
1265
|
+
journalStore: store,
|
|
1108
1266
|
runId,
|
|
1109
1267
|
now: () => t,
|
|
1110
|
-
fx:
|
|
1111
|
-
storeRuntime: bootResult?.store,
|
|
1112
|
-
signalRuntime: bootResult?.signal,
|
|
1113
|
-
vaultRuntime: bootResult?.vault,
|
|
1114
|
-
channelRuntime: bootResult?.channel,
|
|
1115
|
-
aiRuntime: bootResult?.ai,
|
|
1116
|
-
},
|
|
1268
|
+
fx: durableResumeFx(),
|
|
1117
1269
|
});
|
|
1118
1270
|
if (result.status === "sleeping") {
|
|
1119
1271
|
sleepingRuns.set(runId, {
|
|
@@ -1180,7 +1332,7 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
1180
1332
|
const url = new URL(request.url);
|
|
1181
1333
|
const method = request.method.toUpperCase();
|
|
1182
1334
|
|
|
1183
|
-
const respond = (response: Response): Response => {
|
|
1335
|
+
const respond = async (response: Response): Promise<Response> => {
|
|
1184
1336
|
if (shouldLogDevRequests()) {
|
|
1185
1337
|
logDevRequest({
|
|
1186
1338
|
surface: currentDevSurface(),
|
|
@@ -1189,6 +1341,7 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
1189
1341
|
flow: flowLabel,
|
|
1190
1342
|
status: response.status,
|
|
1191
1343
|
ms: Math.round(performance.now() - started),
|
|
1344
|
+
detail: await failureDetailFromResponse(response),
|
|
1192
1345
|
});
|
|
1193
1346
|
}
|
|
1194
1347
|
return response;
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
resolveSmsDriverId,
|
|
11
11
|
smtpOptionsFromEnv,
|
|
12
12
|
sndrOptionsFromEnv,
|
|
13
|
+
taqnyatMailOptionsFromEnv,
|
|
13
14
|
taqnyatOptionsFromEnv,
|
|
14
15
|
unifonicOptionsFromEnv,
|
|
15
16
|
} from "./channel.ts";
|
|
@@ -24,6 +25,8 @@ const previous = {
|
|
|
24
25
|
sndrBase: process.env.SNDR_BASE_URL,
|
|
25
26
|
taqBearer: process.env.TAQNYAT_BEARER_TOKEN,
|
|
26
27
|
taqSender: process.env.TAQNYAT_SENDER,
|
|
28
|
+
taqMailToken: process.env.TAQNYAT_MAIL_TOKEN,
|
|
29
|
+
taqCampaign: process.env.TAQNYAT_CAMPAIGN,
|
|
27
30
|
msegatUser: process.env.MSEGAT_USERNAME,
|
|
28
31
|
msegatKey: process.env.MSEGAT_API_KEY,
|
|
29
32
|
msegatSender: process.env.MSEGAT_SENDER,
|
|
@@ -41,6 +44,8 @@ afterEach(() => {
|
|
|
41
44
|
restoreEnv("SNDR_BASE_URL", previous.sndrBase);
|
|
42
45
|
restoreEnv("TAQNYAT_BEARER_TOKEN", previous.taqBearer);
|
|
43
46
|
restoreEnv("TAQNYAT_SENDER", previous.taqSender);
|
|
47
|
+
restoreEnv("TAQNYAT_MAIL_TOKEN", previous.taqMailToken);
|
|
48
|
+
restoreEnv("TAQNYAT_CAMPAIGN", previous.taqCampaign);
|
|
44
49
|
restoreEnv("MSEGAT_USERNAME", previous.msegatUser);
|
|
45
50
|
restoreEnv("MSEGAT_API_KEY", previous.msegatKey);
|
|
46
51
|
restoreEnv("MSEGAT_SENDER", previous.msegatSender);
|
|
@@ -117,6 +122,17 @@ describe("bindChannel driver resolution", () => {
|
|
|
117
122
|
process.env.UNIFONIC_SENDER = "Brand";
|
|
118
123
|
expect(unifonicOptionsFromEnv()).toEqual({ appSid: "sid", sender: "Brand" });
|
|
119
124
|
});
|
|
125
|
+
|
|
126
|
+
test("taqnyat-mail env helper", () => {
|
|
127
|
+
process.env.TAQNYAT_MAIL_TOKEN = "bearer-mail";
|
|
128
|
+
process.env.TAQNYAT_CAMPAIGN = "auth";
|
|
129
|
+
expect(taqnyatMailOptionsFromEnv()).toEqual({
|
|
130
|
+
bearerToken: "bearer-mail",
|
|
131
|
+
campaignName: "auth",
|
|
132
|
+
});
|
|
133
|
+
delete process.env.TAQNYAT_CAMPAIGN;
|
|
134
|
+
expect(() => taqnyatMailOptionsFromEnv()).toThrow("TAQNYAT_CAMPAIGN");
|
|
135
|
+
});
|
|
120
136
|
});
|
|
121
137
|
|
|
122
138
|
function restoreEnv(key: string, value: string | undefined): void {
|
|
@@ -9,6 +9,7 @@ import { openResendChannel } from "../../drivers/channel-resend.ts";
|
|
|
9
9
|
import { openSmtpChannel } from "../../drivers/channel-smtp.ts";
|
|
10
10
|
import { openSndrChannel } from "../../drivers/channel-sndr.ts";
|
|
11
11
|
import { openTaqnyatChannel } from "../../drivers/channel-taqnyat.ts";
|
|
12
|
+
import { openTaqnyatMailChannel } from "../../drivers/channel-taqnyat-mail.ts";
|
|
12
13
|
import { openUnifonicChannel } from "../../drivers/channel-unifonic.ts";
|
|
13
14
|
import type { ChannelDriver, ChannelOpenOptions } from "../../drivers/channel-types.ts";
|
|
14
15
|
import { createChannelRuntime, type ChannelRuntime } from "../../elements/channel.ts";
|
|
@@ -51,6 +52,7 @@ function emailDriverFor(options: BootOptions, env: ConfigEnv, docker: boolean):
|
|
|
51
52
|
if (id === "smtp") return openSmtpChannel(smtpOptionsFromEnv(docker));
|
|
52
53
|
if (id === "resend") return openResendChannel(resendOptionsFromEnv());
|
|
53
54
|
if (id === "sndr") return openSndrChannel(sndrOptionsFromEnv());
|
|
55
|
+
if (id === "taqnyat-mail") return openTaqnyatMailChannel(taqnyatMailOptionsFromEnv());
|
|
54
56
|
throw new Error(`oke boot: unknown email channel driver "${id}"`);
|
|
55
57
|
}
|
|
56
58
|
|
|
@@ -150,6 +152,17 @@ export function taqnyatOptionsFromEnv(): ChannelOpenOptions {
|
|
|
150
152
|
return { bearerToken, sender };
|
|
151
153
|
}
|
|
152
154
|
|
|
155
|
+
/**
|
|
156
|
+
* Resolve Taqnyat Email options from env.
|
|
157
|
+
*/
|
|
158
|
+
export function taqnyatMailOptionsFromEnv(): ChannelOpenOptions {
|
|
159
|
+
const bearerToken = process.env.TAQNYAT_MAIL_TOKEN?.trim();
|
|
160
|
+
const campaignName = process.env.TAQNYAT_CAMPAIGN?.trim();
|
|
161
|
+
if (!bearerToken) throw new Error("oke boot: taqnyat-mail channel needs TAQNYAT_MAIL_TOKEN");
|
|
162
|
+
if (!campaignName) throw new Error("oke boot: taqnyat-mail channel needs TAQNYAT_CAMPAIGN");
|
|
163
|
+
return { bearerToken, campaignName };
|
|
164
|
+
}
|
|
165
|
+
|
|
153
166
|
/**
|
|
154
167
|
* Resolve Msegat SMS options from env.
|
|
155
168
|
*/
|
|
@@ -2,13 +2,18 @@
|
|
|
2
2
|
* Lazy clock binder — loaded only when Clock is declared.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import { mkdirSync } from "node:fs";
|
|
6
|
+
import { dirname, resolve } from "node:path";
|
|
5
7
|
import {
|
|
6
8
|
clock as declareClock,
|
|
7
9
|
createClockRuntime,
|
|
10
|
+
createFileCronStore,
|
|
11
|
+
createMemoryCronStore,
|
|
8
12
|
createTestClockRuntime,
|
|
9
13
|
type ClockDecl,
|
|
10
14
|
type ClockRuntime,
|
|
11
15
|
} from "../../elements/clock.ts";
|
|
16
|
+
import { createPostgresCronStore } from "../../drivers/clock-postgres.ts";
|
|
12
17
|
import { resolveDriverId, type ConfigEnv } from "../../config/index.ts";
|
|
13
18
|
import type { BootOptions } from "../boot.ts";
|
|
14
19
|
|
|
@@ -18,9 +23,26 @@ export interface BindClockResult {
|
|
|
18
23
|
readonly clockDecls: ReadonlyMap<string, ClockDecl>;
|
|
19
24
|
}
|
|
20
25
|
|
|
26
|
+
/** Default on-disk cron store path (multi-process leader election). */
|
|
27
|
+
export const DEFAULT_FILE_CRON_PATH = ".oke/crons.json";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Resolve `drivers.clock` for the active env.
|
|
31
|
+
*
|
|
32
|
+
* @param options - Boot options
|
|
33
|
+
* @param env - Active environment
|
|
34
|
+
*/
|
|
35
|
+
export function resolveClockDriverId(options: BootOptions, env: ConfigEnv): string {
|
|
36
|
+
const resolved = resolveDriverId(options.config?.drivers?.clock, env);
|
|
37
|
+
if (resolved) return resolved;
|
|
38
|
+
return env === "test" ? "frozen" : "memory";
|
|
39
|
+
}
|
|
40
|
+
|
|
21
41
|
/**
|
|
22
42
|
* Construct / adopt a Clock runtime, register decls, reconcile.
|
|
23
43
|
*
|
|
44
|
+
* Supported ids: `frozen` · `memory` · `file` · `postgres`.
|
|
45
|
+
*
|
|
24
46
|
* @param options - Boot options
|
|
25
47
|
* @param env - Active environment
|
|
26
48
|
* @param now - Clock
|
|
@@ -32,13 +54,47 @@ export async function bindClock(
|
|
|
32
54
|
now: () => number,
|
|
33
55
|
prebuilt?: ClockRuntime,
|
|
34
56
|
): Promise<BindClockResult> {
|
|
35
|
-
const clockDriver =
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
57
|
+
const clockDriver = resolveClockDriverId(options, env);
|
|
58
|
+
|
|
59
|
+
let clock: ClockRuntime;
|
|
60
|
+
if (prebuilt) {
|
|
61
|
+
clock = prebuilt;
|
|
62
|
+
} else if (clockDriver === "frozen" || env === "test") {
|
|
63
|
+
clock = createTestClockRuntime(now(), { instanceId: options.instanceId });
|
|
64
|
+
} else if (clockDriver === "memory") {
|
|
65
|
+
clock = createClockRuntime({
|
|
66
|
+
instanceId: options.instanceId,
|
|
67
|
+
now,
|
|
68
|
+
store: createMemoryCronStore(),
|
|
69
|
+
});
|
|
70
|
+
} else if (clockDriver === "file") {
|
|
71
|
+
const path = resolve(process.cwd(), DEFAULT_FILE_CRON_PATH);
|
|
72
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
73
|
+
clock = createClockRuntime({
|
|
74
|
+
instanceId: options.instanceId,
|
|
75
|
+
now,
|
|
76
|
+
store: createFileCronStore(path),
|
|
77
|
+
});
|
|
78
|
+
} else if (clockDriver === "postgres") {
|
|
79
|
+
const url = process.env.DATABASE_URL ?? process.env.OKE_STORE_SQL_URL ?? undefined;
|
|
80
|
+
if (!url) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
env === "docker"
|
|
83
|
+
? 'oke boot: clock driver "postgres" needs DATABASE_URL (did `oke dev -d` write docker/.env.docker?)'
|
|
84
|
+
: 'oke boot: clock driver "postgres" needs DATABASE_URL',
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
const store = await createPostgresCronStore({ url });
|
|
88
|
+
clock = createClockRuntime({
|
|
89
|
+
instanceId: options.instanceId,
|
|
90
|
+
now,
|
|
91
|
+
store,
|
|
92
|
+
});
|
|
93
|
+
} else {
|
|
94
|
+
throw new Error(
|
|
95
|
+
`oke boot: unknown clock driver "${clockDriver}" (expected memory · file · postgres · frozen)`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
42
98
|
|
|
43
99
|
const clockDecls = new Map<string, ClockDecl>();
|
|
44
100
|
for (const c of options.clocks ?? []) {
|
|
@@ -2,18 +2,86 @@
|
|
|
2
2
|
* Lazy gate binder — loaded only when Gate is declared (or required by AI).
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import { resolveDriverId, type ConfigEnv } from "../../config/index.ts";
|
|
5
6
|
import { memoryDrivers } from "../../drivers/memory.ts";
|
|
7
|
+
import { redisDriver } from "../../drivers/redis.ts";
|
|
8
|
+
import type { KvClientLike } from "../../drivers/types.ts";
|
|
6
9
|
import { createGateRuntime, type GateRuntime } from "../../elements/gate.ts";
|
|
7
10
|
import type { BootOptions } from "../boot.ts";
|
|
8
11
|
|
|
12
|
+
/** Namespace for gate rate-limit counters (never shares an app kv declaration). */
|
|
13
|
+
export const GATE_KV_NAMESPACE = "oke:gates";
|
|
14
|
+
|
|
9
15
|
/**
|
|
10
|
-
*
|
|
16
|
+
* Resolve the KV backend for Gate rates — same facet as `drivers.store.kv`.
|
|
17
|
+
*
|
|
18
|
+
* @param options - Boot options
|
|
19
|
+
* @param env - Active environment
|
|
20
|
+
* @param docker - Docker mode
|
|
21
|
+
*/
|
|
22
|
+
export function resolveGateKvDriverId(
|
|
23
|
+
options: BootOptions,
|
|
24
|
+
env: ConfigEnv,
|
|
25
|
+
docker = false,
|
|
26
|
+
): "memory" | "redis" {
|
|
27
|
+
const fromEnv = process.env.OKE_KV_DRIVER?.trim();
|
|
28
|
+
if (docker && fromEnv === "redis") return "redis";
|
|
29
|
+
if (docker && fromEnv === "memory") return "memory";
|
|
30
|
+
const resolved = resolveDriverId(options.config?.drivers?.store?.kv, env);
|
|
31
|
+
if (resolved === "redis") return "redis";
|
|
32
|
+
if (resolved === "memory") return "memory";
|
|
33
|
+
return docker ? "redis" : "memory";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function kvUrlFor(docker: boolean): string | undefined {
|
|
37
|
+
const url = process.env.REDIS_URL ?? process.env.OKE_STORE_KV_URL ?? undefined;
|
|
38
|
+
if (!url) {
|
|
39
|
+
if (docker) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
"oke boot: gate redis kv needs REDIS_URL (did `oke dev -d` write docker/.env.docker?)",
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
return url;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Construct a Gate runtime backed by `drivers.store.kv` (namespace {@link GATE_KV_NAMESPACE}).
|
|
11
51
|
*
|
|
12
52
|
* @param options - Boot options
|
|
13
53
|
* @param now - Clock
|
|
54
|
+
* @param env - Active environment
|
|
55
|
+
* @param docker - Docker mode
|
|
14
56
|
*/
|
|
15
|
-
export async function bindGate(
|
|
16
|
-
|
|
57
|
+
export async function bindGate(
|
|
58
|
+
options: BootOptions,
|
|
59
|
+
now: () => number,
|
|
60
|
+
env: ConfigEnv = "local",
|
|
61
|
+
docker = false,
|
|
62
|
+
): Promise<GateRuntime> {
|
|
63
|
+
const kvId = resolveGateKvDriverId(options, env, docker);
|
|
64
|
+
const injected = options.clients?.kv as KvClientLike | undefined;
|
|
65
|
+
|
|
66
|
+
let kvNs;
|
|
67
|
+
if (kvId === "redis") {
|
|
68
|
+
const url = injected ? undefined : kvUrlFor(docker);
|
|
69
|
+
if (!injected && url === undefined) {
|
|
70
|
+
// Local without REDIS_URL — soft-fallback to memory (same as missing redis for store would fail;
|
|
71
|
+
// gate rates should not brick local apps that only declared policy gates).
|
|
72
|
+
kvNs = await memoryDrivers.kv.open({ name: GATE_KV_NAMESPACE, nowMs: now });
|
|
73
|
+
} else {
|
|
74
|
+
kvNs = await redisDriver.open({
|
|
75
|
+
name: GATE_KV_NAMESPACE,
|
|
76
|
+
...(url !== undefined ? { url } : {}),
|
|
77
|
+
...(injected !== undefined ? { client: injected } : {}),
|
|
78
|
+
nowMs: now,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
} else {
|
|
82
|
+
kvNs = await memoryDrivers.kv.open({ name: GATE_KV_NAMESPACE, nowMs: now });
|
|
83
|
+
}
|
|
84
|
+
|
|
17
85
|
return createGateRuntime({
|
|
18
86
|
gates: options.gates ?? [],
|
|
19
87
|
kv: kvNs,
|