okengine 0.3.5 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/AGENTS.md +6 -0
  2. package/manifest.v1.schema.json +43 -1
  3. package/package.json +27 -12
  4. package/site/content/docs/ai/skills.mdx +11 -7
  5. package/site/content/docs/console/vault.mdx +4 -0
  6. package/site/content/docs/elements/ai.mdx +2 -0
  7. package/site/content/docs/elements/channel.mdx +7 -4
  8. package/site/content/docs/elements/clock.mdx +2 -4
  9. package/site/content/docs/elements/flow.mdx +19 -10
  10. package/site/content/docs/elements/gate.mdx +18 -11
  11. package/site/content/docs/elements/signal.mdx +9 -12
  12. package/site/content/docs/elements/store.mdx +69 -8
  13. package/site/content/docs/elements/vault.mdx +11 -12
  14. package/site/content/docs/get-started/basic-usage.mdx +76 -41
  15. package/site/content/docs/get-started/installation.mdx +95 -43
  16. package/site/content/docs/get-started/introduction.mdx +128 -75
  17. package/site/content/docs/get-started/meta.json +1 -1
  18. package/site/content/docs/get-started/why.mdx +141 -0
  19. package/site/content/docs/plugins/ip-allowlist.mdx +1 -2
  20. package/site/content/docs/plugins/security-headers.mdx +1 -1
  21. package/site/content/docs/reference/configuration.mdx +2 -2
  22. package/site/content/docs/reference/environment-variables.mdx +10 -0
  23. package/site/content/docs/reference/fx.mdx +56 -3
  24. package/site/content/docs/reference/plugins.mdx +18 -18
  25. package/src/cli/competitor-mention-removal.test.ts +117 -0
  26. package/src/cli/dev.ts +20 -0
  27. package/src/cli/meilisearch-local.test.ts +69 -0
  28. package/src/cli/meilisearch-local.ts +188 -0
  29. package/src/compiler/extract.test.ts +63 -0
  30. package/src/compiler/extract.ts +36 -15
  31. package/src/compiler/fixtures/skyport/src/flows/payments/index.ts +5 -1
  32. package/src/console/server/channels.ts +2 -0
  33. package/src/console/server/clock.ts +3 -0
  34. package/src/console/server/flows.ts +13 -0
  35. package/src/console/server/gates.ts +2 -0
  36. package/src/console/server/plugins.ts +20 -1
  37. package/src/console/server/signals.ts +5 -0
  38. package/src/console/server/store.test.ts +17 -0
  39. package/src/console/server/store.ts +35 -1
  40. package/src/console/ui/channels/types.ts +1 -0
  41. package/src/console/ui/clock/types.ts +1 -0
  42. package/src/console/ui/display.test.ts +14 -0
  43. package/src/console/ui/display.ts +9 -0
  44. package/src/console/ui/dist/assets/index-CjxwRGVv.js +10 -0
  45. package/src/console/ui/dist/assets/panel-access-BGv45snf.js +64 -0
  46. package/src/console/ui/dist/assets/{panel-ai-D_m6WQI8.js → panel-ai-B2S7LEii.js} +1 -1
  47. package/src/console/ui/dist/assets/{panel-architecture-CKnXFyUx.js → panel-architecture-D7UJh91v.js} +1 -1
  48. package/src/console/ui/dist/assets/panel-channels-9T3ybqRu.js +1 -0
  49. package/src/console/ui/dist/assets/panel-clock-Cb1UXGRQ.js +1 -0
  50. package/src/console/ui/dist/assets/{panel-diff-cdonmH8c.js → panel-diff-DmYbKWmN.js} +1 -1
  51. package/src/console/ui/dist/assets/panel-flows-PiHwT55z.js +48 -0
  52. package/src/console/ui/dist/assets/panel-gates-BQGYXvjT.js +1 -0
  53. package/src/console/ui/dist/assets/panel-overview-BBnRO18l.js +1 -0
  54. package/src/console/ui/dist/assets/panel-plugins-D0PsmVw2.js +1 -0
  55. package/src/console/ui/dist/assets/panel-runs-CWuRDe0r.js +1 -0
  56. package/src/console/ui/dist/assets/panel-signals-Bbg4ewpP.js +1 -0
  57. package/src/console/ui/dist/assets/panel-store-CPCbsDRa.js +1 -0
  58. package/src/console/ui/dist/assets/panel-traces-DVAzuA_S.js +1 -0
  59. package/src/console/ui/dist/assets/panel-vault-D1_MvOmo.js +1 -0
  60. package/src/console/ui/dist/assets/{rolldown-runtime-CNC7AqOf.js → rolldown-runtime-B0Z9INg1.js} +1 -1
  61. package/src/console/ui/dist/index.html +2 -2
  62. package/src/console/ui/gates/types.ts +1 -0
  63. package/src/console/ui/plugins/fixture.ts +7 -0
  64. package/src/console/ui/plugins/types.ts +3 -0
  65. package/src/console/ui/shell/client.ts +3 -0
  66. package/src/console/ui/shell/panels/channels/ChannelsPanel.tsx +7 -2
  67. package/src/console/ui/shell/panels/clock/ClockPanel.tsx +9 -2
  68. package/src/console/ui/shell/panels/gates/GatesPanel.tsx +12 -5
  69. package/src/console/ui/shell/panels/plugins/PluginsPanel.tsx +18 -4
  70. package/src/console/ui/shell/panels/signals/SignalsPanel.tsx +13 -2
  71. package/src/console/ui/shell/panels/store/StorePanel.tsx +11 -4
  72. package/src/console/ui/shell/panels/vault/VaultPanel.tsx +9 -3
  73. package/src/console/ui/signals/types.ts +1 -0
  74. package/src/console/ui/store/fixture.ts +5 -0
  75. package/src/console/ui/store/types.ts +2 -0
  76. package/src/docker/compose.ts +5 -0
  77. package/src/docker/docker.test.ts +41 -0
  78. package/src/docker/recipes/index.ts +10 -2
  79. package/src/docker/recipes/meilisearch.ts +31 -0
  80. package/src/drivers/conformance.test.ts +26 -0
  81. package/src/drivers/conformance.ts +40 -3
  82. package/src/drivers/drizzle-dialect.test.ts +4 -0
  83. package/src/drivers/drizzle-dialect.ts +8 -4
  84. package/src/drivers/index.ts +18 -2
  85. package/src/drivers/libsql.ts +179 -0
  86. package/src/drivers/meilisearch.integration.test.ts +77 -0
  87. package/src/drivers/meilisearch.test.ts +181 -0
  88. package/src/drivers/meilisearch.ts +208 -0
  89. package/src/drivers/memory.ts +4 -4
  90. package/src/drivers/pglite.ts +79 -0
  91. package/src/drivers/pgvector.ts +60 -25
  92. package/src/drivers/types.ts +106 -16
  93. package/src/drivers/vault-driver-removal.test.ts +6 -0
  94. package/src/drivers/vault-types.ts +4 -4
  95. package/src/elements/ai/runtime.ts +6 -0
  96. package/src/elements/ai.test.ts +22 -0
  97. package/src/elements/channel/declare.ts +5 -0
  98. package/src/elements/clock/declare.ts +5 -0
  99. package/src/elements/clock/durable.ts +7 -1
  100. package/src/elements/gate/declare.ts +28 -5
  101. package/src/elements/gate.ts +1 -0
  102. package/src/elements/signal/declare.ts +5 -0
  103. package/src/elements/store/declare.ts +10 -2
  104. package/src/elements/store/index-boot.test.ts +299 -0
  105. package/src/elements/store/runtime.ts +110 -17
  106. package/src/elements/store/schema-decl.ts +7 -0
  107. package/src/elements/store.ts +2 -0
  108. package/src/elements/vault.test.ts +27 -4
  109. package/src/elements/vault.ts +1 -1
  110. package/src/index.ts +4 -0
  111. package/src/kernel/abort-scope.ts +116 -0
  112. package/src/kernel/app.ts +12 -2
  113. package/src/kernel/boot-bind/store.test.ts +68 -1
  114. package/src/kernel/boot-bind/store.ts +92 -2
  115. package/src/kernel/concurrency.test.ts +294 -0
  116. package/src/kernel/concurrency.ts +220 -0
  117. package/src/kernel/flow.ts +9 -0
  118. package/src/kernel/fx.test.ts +23 -2
  119. package/src/kernel/fx.ts +80 -4
  120. package/src/kernel/index.ts +23 -0
  121. package/src/kernel/journal.ts +9 -0
  122. package/src/kernel/plugin/capabilities.test.ts +18 -0
  123. package/src/kernel/plugin.ts +11 -3
  124. package/src/kernel/redacted.ts +74 -0
  125. package/src/kernel/registry.ts +29 -6
  126. package/src/kernel/router.ts +3 -3
  127. package/src/manifest/types.ts +21 -0
  128. package/src/release/measure.ts +4 -0
  129. package/src/test/provisions.integration.test.ts +1 -1
  130. package/site/content/docs/get-started/comparison.mdx +0 -65
  131. package/src/console/ui/dist/assets/index-BWo8R7NR.js +0 -10
  132. package/src/console/ui/dist/assets/panel-access-C0J2D-a2.js +0 -64
  133. package/src/console/ui/dist/assets/panel-channels-BOmQ-onL.js +0 -1
  134. package/src/console/ui/dist/assets/panel-clock-giAq0Ccv.js +0 -1
  135. package/src/console/ui/dist/assets/panel-flows-DlCU5zjA.js +0 -45
  136. package/src/console/ui/dist/assets/panel-gates-XclZxWD5.js +0 -1
  137. package/src/console/ui/dist/assets/panel-overview-BznEOTnb.js +0 -1
  138. package/src/console/ui/dist/assets/panel-plugins-CcGM1g64.js +0 -1
  139. package/src/console/ui/dist/assets/panel-runs-CGWNHLR4.js +0 -1
  140. package/src/console/ui/dist/assets/panel-signals-CNywkdak.js +0 -1
  141. package/src/console/ui/dist/assets/panel-store-KmTbFHMH.js +0 -1
  142. package/src/console/ui/dist/assets/panel-traces-DBLx2ilD.js +0 -1
  143. package/src/console/ui/dist/assets/panel-vault-CEnFc0dk.js +0 -1
  144. package/src/drivers/vault-infisical.ts +0 -57
@@ -0,0 +1,294 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { currentAbortSignal, isAbortError, withAbortSignal } from "./abort-scope.ts";
4
+ import { createMemoryJournalStore } from "./journal.ts";
5
+ import { createFx, createFxContext } from "./fx.ts";
6
+ import { flow } from "./flow.ts";
7
+ import { runDurable } from "../elements/clock/durable.ts";
8
+ import { fxAll, fxRace } from "./concurrency.ts";
9
+
10
+ describe("fx.all — structured concurrency", () => {
11
+ test("resolves tuple results in order", async () => {
12
+ const fx = createFx({ flow: "t", effects: {} });
13
+ const out = await fx.all([async () => 1, async () => "a", () => true]);
14
+ expect(out).toEqual([1, "a", true]);
15
+ });
16
+
17
+ test("on failure aborts sibling branches via ambient signal", async () => {
18
+ const fx = createFx({ flow: "t", effects: {} });
19
+ let siblingAborted = false;
20
+ let loserSawAbort = false;
21
+
22
+ await expect(
23
+ fx.all([
24
+ async () => {
25
+ await new Promise<void>((_resolve, reject) => {
26
+ const signal = fx.signal;
27
+ if (signal.aborted) {
28
+ loserSawAbort = true;
29
+ reject(signal.reason ?? new Error("aborted"));
30
+ return;
31
+ }
32
+ signal.addEventListener(
33
+ "abort",
34
+ () => {
35
+ siblingAborted = true;
36
+ loserSawAbort = true;
37
+ reject(new DOMException("aborted", "AbortError"));
38
+ },
39
+ { once: true },
40
+ );
41
+ // Stay pending until aborted.
42
+ });
43
+ },
44
+ async () => {
45
+ await new Promise((r) => setTimeout(r, 5));
46
+ throw new Error("boom");
47
+ },
48
+ ]),
49
+ ).rejects.toThrow("boom");
50
+
51
+ expect(siblingAborted || loserSawAbort).toBe(true);
52
+ });
53
+
54
+ test("empty all resolves to []", async () => {
55
+ await expect(fxAll([])).resolves.toEqual([]);
56
+ });
57
+ });
58
+
59
+ describe("fx.race — structured concurrency", () => {
60
+ test("winner wins and loser sees abort", async () => {
61
+ const fx = createFx({ flow: "t", effects: {} });
62
+ let loserAborted = false;
63
+
64
+ const value = await fx.race([
65
+ async () => {
66
+ await new Promise<void>((_resolve, reject) => {
67
+ fx.signal.addEventListener(
68
+ "abort",
69
+ () => {
70
+ loserAborted = true;
71
+ reject(new DOMException("aborted", "AbortError"));
72
+ },
73
+ { once: true },
74
+ );
75
+ });
76
+ return "slow";
77
+ },
78
+ async () => {
79
+ await new Promise((r) => setTimeout(r, 5));
80
+ return "fast";
81
+ },
82
+ ]);
83
+
84
+ expect(value).toBe("fast");
85
+ // Give the abort listener a tick.
86
+ await new Promise((r) => setTimeout(r, 10));
87
+ expect(loserAborted).toBe(true);
88
+ });
89
+
90
+ test("empty race throws", async () => {
91
+ await expect(fxRace([])).rejects.toThrow(/empty/);
92
+ });
93
+ });
94
+
95
+ describe("fx.using — scoped cleanup", () => {
96
+ test("release runs on success and on thrown error", async () => {
97
+ const fx = createFx({ flow: "t", effects: {} });
98
+ const released: string[] = [];
99
+
100
+ const ok = await fx.using(
101
+ async () => "res-a",
102
+ (r) => {
103
+ released.push(`ok:${r}`);
104
+ },
105
+ async (r) => r.toUpperCase(),
106
+ );
107
+ expect(ok).toBe("RES-A");
108
+ expect(released).toEqual(["ok:res-a"]);
109
+
110
+ await expect(
111
+ fx.using(
112
+ async () => "res-b",
113
+ async (r) => {
114
+ released.push(`err:${r}`);
115
+ },
116
+ async () => {
117
+ throw new Error("boom");
118
+ },
119
+ ),
120
+ ).rejects.toThrow("boom");
121
+ expect(released).toEqual(["ok:res-a", "err:res-b"]);
122
+ });
123
+
124
+ test("release runs when a sibling fx.race winner aborts mid-use", async () => {
125
+ const fx = createFx({ flow: "t", effects: {} });
126
+ let released = false;
127
+
128
+ const winner = await fx.race([
129
+ () =>
130
+ fx.using(
131
+ async () => "lock",
132
+ () => {
133
+ released = true;
134
+ },
135
+ () =>
136
+ new Promise<never>(() => {
137
+ // Stay pending until ambient abort releases us.
138
+ }),
139
+ ),
140
+ async () => {
141
+ await new Promise((r) => setTimeout(r, 5));
142
+ return "fast";
143
+ },
144
+ ]);
145
+
146
+ expect(winner).toBe("fast");
147
+ // Let the abort listener settle.
148
+ await new Promise((r) => setTimeout(r, 20));
149
+ expect(released).toBe(true);
150
+ });
151
+ });
152
+
153
+ describe("fx.retry", () => {
154
+ test("retries then succeeds", async () => {
155
+ const fx = createFx({ flow: "t", effects: {} });
156
+ let n = 0;
157
+ const value = await fx.retry(
158
+ async () => {
159
+ n += 1;
160
+ if (n < 3) throw new Error("transient");
161
+ return "ok";
162
+ },
163
+ { retries: 3, delay: 0, jitter: false },
164
+ );
165
+ expect(value).toBe("ok");
166
+ expect(n).toBe(3);
167
+ });
168
+
169
+ test("does not retry AbortError", async () => {
170
+ const fx = createFx({ flow: "t", effects: {} });
171
+ let n = 0;
172
+ await expect(
173
+ fx.retry(
174
+ async () => {
175
+ n += 1;
176
+ throw new DOMException("aborted", "AbortError");
177
+ },
178
+ { retries: 3, delay: 0 },
179
+ ),
180
+ ).rejects.toMatchObject({ name: "AbortError" });
181
+ expect(n).toBe(1);
182
+ });
183
+
184
+ test("honors when predicate", async () => {
185
+ const fx = createFx({ flow: "t", effects: {} });
186
+ let n = 0;
187
+ await expect(
188
+ fx.retry(
189
+ async () => {
190
+ n += 1;
191
+ throw new Error("nope");
192
+ },
193
+ { retries: 5, delay: 0, when: () => false },
194
+ ),
195
+ ).rejects.toThrow("nope");
196
+ expect(n).toBe(1);
197
+ });
198
+ });
199
+
200
+ describe("ambient abort signal", () => {
201
+ test("outside scope is never aborted", () => {
202
+ const fx = createFx({ flow: "t", effects: {} });
203
+ expect(fx.signal.aborted).toBe(false);
204
+ expect(isAbortError(new DOMException("x", "AbortError"))).toBe(true);
205
+ });
206
+
207
+ test("withAbortSignal installs ambient signal", async () => {
208
+ const ctrl = new AbortController();
209
+ await withAbortSignal(ctrl.signal, async () => {
210
+ expect(currentAbortSignal()).toBe(ctrl.signal);
211
+ });
212
+ });
213
+ });
214
+
215
+ describe("retry + durable journal", () => {
216
+ test("fx.retry inside fx.step does not re-run completed step on resume", async () => {
217
+ const journalStore = createMemoryJournalStore();
218
+ let attempts = 0;
219
+ let crash = true;
220
+
221
+ const f = flow({
222
+ name: "pay.retry",
223
+ durable: true,
224
+ do: async (_input, fx) => {
225
+ const charged = await fx.step("charge", () =>
226
+ fx.retry(
227
+ async () => {
228
+ attempts += 1;
229
+ return { id: "ch_1" };
230
+ },
231
+ { retries: 2, delay: 0 },
232
+ ),
233
+ );
234
+ if (crash) throw new Error("KILLED");
235
+ return charged;
236
+ },
237
+ });
238
+
239
+ const first = await runDurable({ flow: f, journalStore });
240
+ expect(first.status).toBe("failed");
241
+ expect(attempts).toBe(1);
242
+
243
+ crash = false;
244
+ const second = await runDurable({
245
+ flow: f,
246
+ journalStore,
247
+ runId: first.status === "failed" ? first.runId : "",
248
+ });
249
+ expect(second.status).toBe("completed");
250
+ expect(attempts).toBe(1);
251
+ });
252
+
253
+ test("flow({ retry }) reuses journal — completed steps do not re-run", async () => {
254
+ const journalStore = createMemoryJournalStore();
255
+ const calls: string[] = [];
256
+ let failOnce = true;
257
+
258
+ const f = flow({
259
+ name: "flow.retry",
260
+ durable: true,
261
+ retry: { retries: 2, delay: 0, jitter: false },
262
+ do: async (_input, fx) => {
263
+ await fx.step("a", () => {
264
+ calls.push("a");
265
+ return 1;
266
+ });
267
+ if (failOnce) {
268
+ failOnce = false;
269
+ throw new Error("transient");
270
+ }
271
+ return fx.step("b", () => {
272
+ calls.push("b");
273
+ return 2;
274
+ });
275
+ },
276
+ });
277
+
278
+ const result = await runDurable({ flow: f, journalStore });
279
+ expect(result.status).toBe("completed");
280
+ // step a ran once; flow-level retry re-entered do but journal replayed a.
281
+ expect(calls).toEqual(["a", "b"]);
282
+ });
283
+ });
284
+
285
+ describe("capability + concurrency compose", () => {
286
+ test("fx.all records effects from successful branches", async () => {
287
+ const { fx, ledger } = createFxContext({
288
+ flow: "parallel",
289
+ effects: { emits: ["a", "b"] },
290
+ });
291
+ await fx.all([() => fx.emit("a"), () => fx.emit("b")]);
292
+ expect(ledger.entries.map((e) => e.resource)).toEqual(["a", "b"]);
293
+ });
294
+ });
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Structured concurrency and retry — plain Promise helpers for `fx`.
3
+ *
4
+ * No generators, no monadic wrapper. Branches are thunks so an AbortController
5
+ * can be installed before work starts. Retry backoff uses non-journaled sleep.
6
+ */
7
+
8
+ import { parseDurationMs } from "../elements/clock/duration.ts";
9
+ import {
10
+ abortableSleep,
11
+ abortError,
12
+ currentAbortSignal,
13
+ isAbortError,
14
+ linkAbort,
15
+ withAbortSignal,
16
+ } from "./abort-scope.ts";
17
+ import { isJournalSuspend } from "./journal.ts";
18
+
19
+ /** A unit of work started under an abort scope. */
20
+ export type FxThunk<T> = () => T | Promise<T>;
21
+
22
+ /**
23
+ * Retry policy — same shape as the client transport, with duration strings
24
+ * and optional full jitter (no Schedule DSL).
25
+ */
26
+ export interface FxRetryOptions {
27
+ /** Extra attempts after the first (default 0). */
28
+ readonly retries?: number;
29
+ /** Initial delay: ms number or duration string (default 50). */
30
+ readonly delay?: number | string;
31
+ /** Multiplier applied after each retry (default 2). */
32
+ readonly backoff?: number;
33
+ /** Full jitter on delay (default true). */
34
+ readonly jitter?: boolean;
35
+ /** Predicate; default retries thrown errors except abort / journal suspend. */
36
+ readonly when?: (err: unknown) => boolean;
37
+ }
38
+
39
+ /**
40
+ * Default retry filter — skip abort and durable-sleep park.
41
+ *
42
+ * @param err - Thrown value
43
+ */
44
+ export function defaultRetryWhen(err: unknown): boolean {
45
+ if (isAbortError(err)) return false;
46
+ if (isJournalSuspend(err)) return false;
47
+ return true;
48
+ }
49
+
50
+ /**
51
+ * Resolve a delay option to milliseconds.
52
+ *
53
+ * @param delay - Number or duration string
54
+ * @param fallback - Default ms
55
+ */
56
+ export function resolveRetryDelayMs(delay: number | string | undefined, fallback = 50): number {
57
+ if (delay === undefined) return fallback;
58
+ if (typeof delay === "number") return Math.max(0, delay);
59
+ const parsed = parseDurationMs(delay);
60
+ return parsed > 0 ? parsed : fallback;
61
+ }
62
+
63
+ /**
64
+ * Run thunks in parallel; on first rejection abort siblings and rethrow.
65
+ *
66
+ * @param thunks - Work units (not already-started Promises)
67
+ */
68
+ export async function fxAll<const T extends readonly unknown[]>(thunks: {
69
+ readonly [K in keyof T]: FxThunk<T[K]>;
70
+ }): Promise<{ -readonly [K in keyof T]: Awaited<T[K]> }> {
71
+ const list = thunks as readonly FxThunk<unknown>[];
72
+ if (list.length === 0) {
73
+ return [] as { -readonly [K in keyof T]: Awaited<T[K]> };
74
+ }
75
+
76
+ const parent = currentAbortSignal();
77
+ const controllers = list.map(() => new AbortController());
78
+ const unsubs = controllers.map((c) => linkAbort(parent, c));
79
+
80
+ let firstError: unknown;
81
+ const results = await Promise.allSettled(
82
+ list.map((thunk, i) =>
83
+ withAbortSignal(controllers[i]!.signal, async () => {
84
+ try {
85
+ return await thunk();
86
+ } catch (err) {
87
+ if (firstError === undefined) {
88
+ firstError = err;
89
+ for (let j = 0; j < controllers.length; j++) {
90
+ if (j !== i) controllers[j]!.abort(err);
91
+ }
92
+ }
93
+ throw err;
94
+ }
95
+ }),
96
+ ),
97
+ );
98
+
99
+ for (const unsub of unsubs) unsub();
100
+
101
+ if (firstError !== undefined) throw firstError;
102
+ const values: unknown[] = [];
103
+ for (const r of results) {
104
+ if (r.status === "rejected") throw r.reason;
105
+ values.push(r.value);
106
+ }
107
+ return values as { -readonly [K in keyof T]: Awaited<T[K]> };
108
+ }
109
+
110
+ /**
111
+ * Race thunks; the first settle wins and siblings are aborted.
112
+ *
113
+ * @param thunks - Work units
114
+ */
115
+ export async function fxRace<T>(thunks: ReadonlyArray<FxThunk<T>>): Promise<T> {
116
+ if (thunks.length === 0) {
117
+ throw new TypeError("fx.race: empty thunk list");
118
+ }
119
+
120
+ const parent = currentAbortSignal();
121
+ const controllers = thunks.map(() => new AbortController());
122
+ const unsubs = controllers.map((c) => linkAbort(parent, c));
123
+
124
+ return new Promise<T>((resolve, reject) => {
125
+ let done = false;
126
+ const settle = (ok: boolean, value: unknown): void => {
127
+ if (done) return;
128
+ done = true;
129
+ for (const c of controllers) {
130
+ if (!c.signal.aborted) c.abort();
131
+ }
132
+ for (const unsub of unsubs) unsub();
133
+ if (ok) resolve(value as T);
134
+ else reject(value);
135
+ };
136
+
137
+ for (let i = 0; i < thunks.length; i++) {
138
+ const thunk = thunks[i]!;
139
+ const ctrl = controllers[i]!;
140
+ void withAbortSignal(ctrl.signal, () => Promise.resolve().then(thunk)).then(
141
+ (value) => settle(true, value),
142
+ (err: unknown) => settle(false, err),
143
+ );
144
+ }
145
+ });
146
+ }
147
+
148
+ /**
149
+ * Scope a resource to a unit of work — `release` runs exactly once when
150
+ * `use` settles or when the ambient abort signal fires, whichever comes
151
+ * first. Uses the same AbortSignal as {@link Fx.signal}; no second
152
+ * cancellation channel.
153
+ *
154
+ * Process-local only: do not journal acquire/release, and do not hold
155
+ * handles across durable park/resume — journal replay returns values and
156
+ * never re-enters step bodies.
157
+ *
158
+ * @param acquire - Open the resource
159
+ * @param release - Cleanup, always run
160
+ * @param use - Work with the resource
161
+ */
162
+ export async function fxUsing<A, T>(
163
+ acquire: () => A | Promise<A>,
164
+ release: (resource: A) => void | Promise<void>,
165
+ use: (resource: A) => T | Promise<T>,
166
+ ): Promise<T> {
167
+ const resource = await acquire();
168
+ const signal = currentAbortSignal();
169
+ let released = false;
170
+
171
+ const releaseOnce = async (): Promise<void> => {
172
+ if (released) return;
173
+ released = true;
174
+ await release(resource);
175
+ };
176
+
177
+ const onAbort = (): void => {
178
+ void releaseOnce();
179
+ };
180
+
181
+ if (signal.aborted) {
182
+ await releaseOnce();
183
+ throw abortError(signal.reason);
184
+ }
185
+ signal.addEventListener("abort", onAbort);
186
+
187
+ try {
188
+ return await use(resource);
189
+ } finally {
190
+ signal.removeEventListener("abort", onAbort);
191
+ await releaseOnce();
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Retry a thunk with exponential backoff and optional full jitter.
197
+ *
198
+ * @param fn - Operation
199
+ * @param opts - Retry policy
200
+ */
201
+ export async function fxRetry<T>(fn: FxThunk<T>, opts: FxRetryOptions = {}): Promise<T> {
202
+ const retries = Math.max(0, opts.retries ?? 0);
203
+ const backoff = opts.backoff ?? 2;
204
+ const jitter = opts.jitter !== false;
205
+ const when = opts.when ?? defaultRetryWhen;
206
+ let delayMs = resolveRetryDelayMs(opts.delay, 50);
207
+ let attempt = 0;
208
+
209
+ for (;;) {
210
+ try {
211
+ return await fn();
212
+ } catch (err) {
213
+ if (attempt >= retries || !when(err)) throw err;
214
+ const wait = jitter ? Math.random() * delayMs : delayMs;
215
+ await abortableSleep(wait, currentAbortSignal());
216
+ delayMs *= backoff;
217
+ attempt += 1;
218
+ }
219
+ }
220
+ }
@@ -8,6 +8,7 @@
8
8
 
9
9
  import type { Effects, FlowPlane, Slo } from "../manifest/types.ts";
10
10
  import type { InferSchemaOutput, SchemaInput } from "../validation/standard-schema.ts";
11
+ import type { FxRetryOptions } from "./concurrency.ts";
11
12
  import type { FlowFailure } from "./errors.ts";
12
13
  import type { Fx } from "./fx.ts";
13
14
  import type { HookFn, HookStage } from "./hooks.ts";
@@ -51,6 +52,11 @@ export interface FlowOptions<I = unknown, O = unknown, E extends FlowErrorMap =
51
52
  readonly effects?: Effects;
52
53
  /** Journal every effect call. */
53
54
  readonly durable?: boolean;
55
+ /**
56
+ * Retry the whole `do` body on thrown errors (same journal session when
57
+ * durable). Prefer {@link Fx.retry} inside {@link Fx.step} for fine control.
58
+ */
59
+ readonly retry?: FxRetryOptions;
54
60
  /** Live-query / push result. */
55
61
  readonly live?: boolean;
56
62
  /** Cache TTL (`true` = default policy, or a duration string). */
@@ -147,6 +153,8 @@ export interface FlowDef<
147
153
  readonly effects: Effects | undefined;
148
154
  /** Durability flag. */
149
155
  readonly durable: boolean;
156
+ /** Whole-body retry policy (runtime). */
157
+ readonly retry: FxRetryOptions | undefined;
150
158
  /** Live flag. */
151
159
  readonly live: boolean;
152
160
  /** Cache option. */
@@ -222,6 +230,7 @@ export function flow<Opts extends FlowOptions<any, any, any>>(
222
230
  errors: (options.errors ?? undefined) as InferFlowErrors<Opts> | undefined,
223
231
  effects: options.effects,
224
232
  durable: options.durable ?? false,
233
+ retry: options.retry,
225
234
  live: options.live ?? false,
226
235
  cache: options.cache,
227
236
  slo: options.slo,
@@ -9,6 +9,7 @@ import {
9
9
  type Fx,
10
10
  type FxStubStoreHandle,
11
11
  } from "./fx.ts";
12
+ import { Redacted } from "./redacted.ts";
12
13
 
13
14
  /** Narrow stub handle for tests that exercise the in-memory store. */
14
15
  function stub(fx: Fx, ref: string): FxStubStoreHandle {
@@ -99,7 +100,7 @@ describe("fx — effect ledger", () => {
99
100
  await fx.emit("order-placed", {});
100
101
  await fx.send("order-confirmed", { to: "u1" });
101
102
  await fx.ask("triage@1", {});
102
- expect(fx.vault("STRIPE_KEY")).toBe("sk_test");
103
+ expect(fx.vault("STRIPE_KEY").reveal()).toBe("sk_test");
103
104
  await fx.call("payments.charge", {});
104
105
 
105
106
  expect(ledger.entries).toHaveLength(7);
@@ -130,7 +131,7 @@ describe("fx — wholesale swap", () => {
130
131
  sleep: async () => undefined,
131
132
  },
132
133
  vault() {
133
- return "";
134
+ return new Redacted("");
134
135
  },
135
136
  cache: {
136
137
  get: async () => undefined,
@@ -181,6 +182,26 @@ describe("fx — wholesale swap", () => {
181
182
  async step(_name, fn) {
182
183
  return fn();
183
184
  },
185
+ get signal() {
186
+ return new AbortController().signal;
187
+ },
188
+ all(thunks) {
189
+ return Promise.all(thunks.map((t) => t())) as never;
190
+ },
191
+ race(thunks) {
192
+ return Promise.race(thunks.map((t) => t()));
193
+ },
194
+ retry(fn) {
195
+ return Promise.resolve().then(fn);
196
+ },
197
+ async using(acquire, release, use) {
198
+ const resource = await acquire();
199
+ try {
200
+ return await use(resource);
201
+ } finally {
202
+ await release(resource);
203
+ }
204
+ },
184
205
  };
185
206
 
186
207
  // Any code that accepts `Fx` can run against a total replacement.