@nagi-js/core 0.1.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/dist/index.js ADDED
@@ -0,0 +1,1285 @@
1
+ // src/internal.ts
2
+ var DEF = "__def";
3
+ function attachDef(meta, def) {
4
+ return { kind: meta.kind, id: meta.id, [DEF]: def };
5
+ }
6
+ function getDef(step) {
7
+ const def = step[DEF];
8
+ if (def === void 0) {
9
+ throw new Error(
10
+ `Step "${step.id}" has no internal definition. Construct steps via the builder.`
11
+ );
12
+ }
13
+ return def;
14
+ }
15
+ function resolveNeeds(def, loadOutput) {
16
+ const result = {};
17
+ for (const [localKey, upstream] of Object.entries(def.needs)) {
18
+ if (upstream && typeof upstream === "object" && "id" in upstream && typeof upstream.id === "string") {
19
+ result[localKey] = loadOutput(upstream.id);
20
+ }
21
+ }
22
+ return result;
23
+ }
24
+ function readSelectedArm(matchId, runState) {
25
+ for (const fact of runState.facts) {
26
+ if (fact.kind === "match.arm-selected" && fact.stepId === matchId) {
27
+ return fact.arm;
28
+ }
29
+ }
30
+ return null;
31
+ }
32
+ function selectArm(def, args) {
33
+ if (def.mode === "discriminator") {
34
+ const key = def.on(args);
35
+ if (def.arms[key] === void 0) {
36
+ const available = Object.keys(def.arms).join(", ");
37
+ throw new Error(
38
+ `match: discriminator returned "${key}" which has no arm (available: ${available || "<none>"})`
39
+ );
40
+ }
41
+ return key;
42
+ }
43
+ for (const arm of def.arms) {
44
+ if (arm.otherwise) return arm.id;
45
+ if (arm.when?.(args)) return arm.id;
46
+ }
47
+ throw new Error(
48
+ `match: no guard arm matched and no { otherwise: true } fallback was provided`
49
+ );
50
+ }
51
+
52
+ // src/builder.ts
53
+ function isDiscriminator(config) {
54
+ return "cases" in config && "on" in config;
55
+ }
56
+ function makeBuilder() {
57
+ function task(config) {
58
+ const def = {
59
+ kind: "task",
60
+ needs: config.needs ?? {},
61
+ ...config.retry !== void 0 ? { retry: config.retry } : {},
62
+ ...config.timeout !== void 0 ? { timeout: config.timeout } : {},
63
+ ...config.when !== void 0 ? {
64
+ when: config.when
65
+ } : {},
66
+ run: config.run
67
+ };
68
+ return attachDef({ kind: "task", id: "" }, def);
69
+ }
70
+ function signal(config) {
71
+ const def = {
72
+ kind: "signal",
73
+ needs: config.needs ?? {},
74
+ schema: config.schema,
75
+ ...config.timeout !== void 0 ? { timeout: config.timeout } : {},
76
+ ...config.when !== void 0 ? {
77
+ when: config.when
78
+ } : {}
79
+ };
80
+ return attachDef({ kind: "signal", id: "" }, def);
81
+ }
82
+ function match(config) {
83
+ const needs = "needs" in config && config.needs ? config.needs : {};
84
+ if (isDiscriminator(config)) {
85
+ const arms2 = {};
86
+ for (const [caseKey, build] of Object.entries(config.cases)) {
87
+ const nested = build(
88
+ makeBuilder()
89
+ );
90
+ arms2[caseKey] = { id: caseKey, stepIds: [], _nested: nested };
91
+ }
92
+ const def2 = {
93
+ kind: "match",
94
+ mode: "discriminator",
95
+ needs,
96
+ on: config.on,
97
+ arms: arms2
98
+ };
99
+ return attachDef({ kind: "match", id: "" }, def2);
100
+ }
101
+ const guardConfig = config;
102
+ const arms = [];
103
+ for (let i = 0; i < guardConfig.arms.length; i++) {
104
+ const arm = guardConfig.arms[i];
105
+ const nested = arm.build(makeBuilder());
106
+ const armId = arm.otherwise ? "otherwise" : `arm${i}`;
107
+ arms.push({
108
+ id: armId,
109
+ ...arm.when !== void 0 ? {
110
+ when: arm.when
111
+ } : {},
112
+ ...arm.otherwise ? { otherwise: true } : {},
113
+ stepIds: [],
114
+ _nested: nested
115
+ });
116
+ }
117
+ const def = {
118
+ kind: "match",
119
+ mode: "guard",
120
+ needs,
121
+ arms
122
+ };
123
+ return attachDef({ kind: "match", id: "" }, def);
124
+ }
125
+ return { task, signal, match };
126
+ }
127
+ function flow(config) {
128
+ const builder = makeBuilder();
129
+ const built = config.build(builder);
130
+ const idByIdentity = /* @__PURE__ */ new Map();
131
+ collectIds(built, "", idByIdentity);
132
+ const finalSteps = {};
133
+ walkAndRewrite({
134
+ flowId: config.id,
135
+ map: built,
136
+ prefix: "",
137
+ parentMatch: void 0,
138
+ idByIdentity,
139
+ out: finalSteps
140
+ });
141
+ return {
142
+ id: config.id,
143
+ input: config.input,
144
+ steps: finalSteps,
145
+ ...config.output !== void 0 ? { output: config.output } : {}
146
+ };
147
+ }
148
+ function collectIds(map, prefix, idByIdentity) {
149
+ for (const [key, step] of Object.entries(map)) {
150
+ const id = prefix ? `${prefix}.${key}` : key;
151
+ idByIdentity.set(step, id);
152
+ const def = step.__def;
153
+ if (def?.kind === "match") {
154
+ const arms = def.mode === "discriminator" ? Object.values(def.arms) : def.arms;
155
+ for (const arm of arms) {
156
+ if (arm._nested)
157
+ collectIds(arm._nested, `${id}.${arm.id}`, idByIdentity);
158
+ }
159
+ }
160
+ }
161
+ }
162
+ function walkAndRewrite(args) {
163
+ const { flowId, map, prefix, parentMatch, idByIdentity, out } = args;
164
+ for (const [key, step] of Object.entries(map)) {
165
+ const id = prefix ? `${prefix}.${key}` : key;
166
+ const def = step.__def;
167
+ if (def === void 0) {
168
+ throw new Error(
169
+ `Flow "${flowId}": step "${id}" has no internal def. Did you return a value not produced by the builder?`
170
+ );
171
+ }
172
+ if (step.id !== "") {
173
+ throw new Error(
174
+ `Flow "${flowId}": step "${id}" was produced by a different flow() call (its id is already "${step.id}"). Each flow's build must use only the builder passed to it; steps cannot be shared between flows.`
175
+ );
176
+ }
177
+ const rewrittenNeeds = {};
178
+ for (const [localKey, upstream] of Object.entries(def.needs)) {
179
+ const upstreamId = idByIdentity.get(upstream);
180
+ if (upstreamId === void 0) {
181
+ const fromOtherFlow = upstream.id !== "";
182
+ throw new Error(
183
+ fromOtherFlow ? `Flow "${flowId}": step "${id}" needs an upstream step from a different flow() call (upstream id "${upstream.id}"). Steps cannot be shared between flows.` : `Flow "${flowId}": step "${id}" references an upstream step that was not returned from build(). Add it to the returned object.`
184
+ );
185
+ }
186
+ rewrittenNeeds[localKey] = { ...upstream, id: upstreamId };
187
+ }
188
+ if (def.kind === "match") {
189
+ const armList = def.mode === "discriminator" ? Object.values(def.arms) : def.arms;
190
+ const finalizedArms = [];
191
+ for (const arm of armList) {
192
+ const armPrefix = `${id}.${arm.id}`;
193
+ const nestedStepIds = [];
194
+ if (arm._nested) {
195
+ for (const nestedKey of Object.keys(arm._nested)) {
196
+ nestedStepIds.push(`${armPrefix}.${nestedKey}`);
197
+ }
198
+ walkAndRewrite({
199
+ flowId,
200
+ map: arm._nested,
201
+ prefix: armPrefix,
202
+ parentMatch: { matchId: id, armId: arm.id },
203
+ idByIdentity,
204
+ out
205
+ });
206
+ }
207
+ finalizedArms.push({
208
+ id: arm.id,
209
+ ...arm.when !== void 0 ? { when: arm.when } : {},
210
+ ...arm.otherwise ? { otherwise: true } : {},
211
+ stepIds: nestedStepIds
212
+ });
213
+ }
214
+ const finalizedDef2 = def.mode === "discriminator" ? {
215
+ kind: "match",
216
+ mode: "discriminator",
217
+ needs: rewrittenNeeds,
218
+ on: def.on,
219
+ arms: Object.fromEntries(finalizedArms.map((a) => [a.id, a])),
220
+ ...parentMatch ? { parentMatch } : {}
221
+ } : {
222
+ kind: "match",
223
+ mode: "guard",
224
+ needs: rewrittenNeeds,
225
+ arms: finalizedArms,
226
+ ...parentMatch ? { parentMatch } : {}
227
+ };
228
+ out[id] = attachDef({ kind: "match", id }, finalizedDef2);
229
+ continue;
230
+ }
231
+ const baseRewritten = { ...def, needs: rewrittenNeeds };
232
+ const finalizedDef = parentMatch ? { ...baseRewritten, parentMatch } : baseRewritten;
233
+ out[id] = attachDef({ kind: finalizedDef.kind, id }, finalizedDef);
234
+ }
235
+ }
236
+
237
+ // src/memory.ts
238
+ var DEFAULT_STORE_LEASE_MS = 6e4;
239
+ var InMemoryStore = class {
240
+ facts = /* @__PURE__ */ new Map();
241
+ outputs = /* @__PURE__ */ new Map();
242
+ onces = /* @__PURE__ */ new Map();
243
+ leases = /* @__PURE__ */ new Map();
244
+ leaseMs;
245
+ constructor(opts = {}) {
246
+ this.leaseMs = opts.leaseMs ?? DEFAULT_STORE_LEASE_MS;
247
+ }
248
+ async appendFact(runId, fact) {
249
+ const list = this.facts.get(runId) ?? [];
250
+ list.push(fact);
251
+ this.facts.set(runId, list);
252
+ }
253
+ async tryStartRun(runId, fact) {
254
+ if (this.facts.has(runId)) {
255
+ return { started: false };
256
+ }
257
+ this.facts.set(runId, [fact]);
258
+ return { started: true };
259
+ }
260
+ async loadRunState(runId) {
261
+ return projectRunState(runId, this.facts.get(runId) ?? []);
262
+ }
263
+ async claimStep(runId, stepId, attempt) {
264
+ const key = `${runId}::${stepId}::${attempt}`;
265
+ const now = Date.now();
266
+ const existing = this.leases.get(key);
267
+ if (existing && existing.expiresAt > now) {
268
+ return null;
269
+ }
270
+ const token = `lease-${crypto.randomUUID()}`;
271
+ this.leases.set(key, { token, expiresAt: now + this.leaseMs });
272
+ return token;
273
+ }
274
+ async completeStep(runId, stepId, output, fact) {
275
+ this.outputs.set(`${runId}::${stepId}`, output);
276
+ await this.appendFact(runId, fact);
277
+ }
278
+ async failStep(runId, _stepId, _error, fact) {
279
+ await this.appendFact(runId, fact);
280
+ }
281
+ async getStepOutput(runId, stepId) {
282
+ return this.outputs.get(`${runId}::${stepId}`) ?? null;
283
+ }
284
+ async recordOnce(runId, stepId, scope, value) {
285
+ this.onces.set(`${runId}::${stepId}::${scope}`, value);
286
+ }
287
+ async getOnce(runId, stepId, scope) {
288
+ return this.onces.get(`${runId}::${stepId}::${scope}`) ?? null;
289
+ }
290
+ async runStep(runId, stepId, _attempt, body) {
291
+ const result = await body(void 0);
292
+ if (result.fact.kind === "step.completed") {
293
+ await this.completeStep(runId, stepId, result.output, result.fact);
294
+ } else {
295
+ await this.failStep(runId, stepId, result.fact.error, result.fact);
296
+ }
297
+ return result.output;
298
+ }
299
+ };
300
+ function projectRunState(runId, facts) {
301
+ let flowId = "";
302
+ let status = "pending";
303
+ const steps = {};
304
+ for (const fact of facts) {
305
+ switch (fact.kind) {
306
+ case "flow.started":
307
+ flowId = fact.flowId;
308
+ status = "running";
309
+ break;
310
+ case "flow.completed":
311
+ status = "completed";
312
+ break;
313
+ case "flow.failed":
314
+ status = "failed";
315
+ break;
316
+ case "step.started":
317
+ steps[fact.stepId] = {
318
+ stepId: fact.stepId,
319
+ status: "running",
320
+ attempts: fact.attempt
321
+ };
322
+ break;
323
+ case "step.completed":
324
+ steps[fact.stepId] = {
325
+ stepId: fact.stepId,
326
+ status: "completed",
327
+ attempts: fact.attempt,
328
+ output: fact.output
329
+ };
330
+ break;
331
+ case "step.failed":
332
+ steps[fact.stepId] = {
333
+ stepId: fact.stepId,
334
+ status: "failed",
335
+ attempts: fact.attempt,
336
+ error: fact.error
337
+ };
338
+ break;
339
+ case "step.skipped":
340
+ steps[fact.stepId] = {
341
+ stepId: fact.stepId,
342
+ status: "skipped",
343
+ attempts: 0
344
+ };
345
+ break;
346
+ }
347
+ }
348
+ return { runId, flowId, status, steps, facts };
349
+ }
350
+ var DEFAULT_QUEUE_LEASE_MS = 6e4;
351
+ var InMemoryQueue = class {
352
+ pending = [];
353
+ leased = /* @__PURE__ */ new Map();
354
+ leaseMs;
355
+ constructor(opts = {}) {
356
+ this.leaseMs = opts.leaseMs ?? DEFAULT_QUEUE_LEASE_MS;
357
+ }
358
+ async enqueue(runId, stepId, opts) {
359
+ const now = Date.now();
360
+ const item = {
361
+ receipt: crypto.randomUUID(),
362
+ runId,
363
+ stepId,
364
+ payload: opts?.payload ?? null,
365
+ attempt: opts?.attempt ?? 1,
366
+ enqueuedAt: now,
367
+ visibleAt: now + (opts?.delayMs ?? 0)
368
+ };
369
+ this.pending.push(item);
370
+ }
371
+ async dequeue(opts) {
372
+ const now = Date.now();
373
+ const claimed = [];
374
+ for (let i = 0; i < this.pending.length && claimed.length < opts.count; i++) {
375
+ const item = this.pending[i];
376
+ if (item === void 0) continue;
377
+ if (item.visibleAt > now) continue;
378
+ const next = { ...item, visibleAt: now + this.leaseMs };
379
+ this.pending.splice(i, 1);
380
+ i--;
381
+ this.leased.set(item.receipt, next);
382
+ claimed.push(item);
383
+ }
384
+ return claimed;
385
+ }
386
+ async ack(receipt) {
387
+ this.leased.delete(receipt);
388
+ }
389
+ async nack(receipt, opts) {
390
+ const item = this.leased.get(receipt);
391
+ if (!item) return;
392
+ this.leased.delete(receipt);
393
+ const requeued = {
394
+ ...item,
395
+ visibleAt: Date.now() + (opts?.delayMs ?? 0)
396
+ };
397
+ this.pending.push(requeued);
398
+ }
399
+ async extend(receipt, leaseMs) {
400
+ const item = this.leased.get(receipt);
401
+ if (!item) return;
402
+ this.leased.set(receipt, { ...item, visibleAt: Date.now() + leaseMs });
403
+ }
404
+ };
405
+ var InMemoryClock = class {
406
+ timers = /* @__PURE__ */ new Map();
407
+ trigger;
408
+ constructor(opts = {}) {
409
+ this.trigger = opts.trigger;
410
+ }
411
+ now() {
412
+ return /* @__PURE__ */ new Date();
413
+ }
414
+ async sleep(ms, signal) {
415
+ if (signal?.aborted) {
416
+ throw signal.reason ?? new Error("aborted");
417
+ }
418
+ return new Promise((resolve, reject) => {
419
+ const timer = setTimeout(() => {
420
+ signal?.removeEventListener("abort", onAbort);
421
+ resolve();
422
+ }, ms);
423
+ const onAbort = () => {
424
+ clearTimeout(timer);
425
+ reject(signal?.reason ?? new Error("aborted"));
426
+ };
427
+ signal?.addEventListener("abort", onAbort, { once: true });
428
+ });
429
+ }
430
+ async schedule(at, runId, stepId) {
431
+ const key = `${runId}::${stepId}`;
432
+ const existing = this.timers.get(key);
433
+ if (existing !== void 0) clearTimeout(existing);
434
+ const delay = Math.max(0, at.getTime() - Date.now());
435
+ const handle = setTimeout(() => {
436
+ this.timers.delete(key);
437
+ this.trigger?.fire(runId);
438
+ }, delay);
439
+ this.timers.set(key, handle);
440
+ }
441
+ /** Clear any pending scheduled timers. Call from test teardown. */
442
+ dispose() {
443
+ for (const t of this.timers.values()) clearTimeout(t);
444
+ this.timers.clear();
445
+ }
446
+ };
447
+ var InMemoryTrigger = class {
448
+ handlers = [];
449
+ subscribe(handler) {
450
+ this.handlers.push(handler);
451
+ return () => {
452
+ this.handlers = this.handlers.filter((h) => h !== handler);
453
+ };
454
+ }
455
+ /**
456
+ * Test-only: synchronously dispatch a wake-up to all subscribers.
457
+ * Real triggers fire from a Store change (NOTIFY/LISTEN, polling, etc.).
458
+ */
459
+ fire(runId) {
460
+ for (const h of this.handlers) h(runId);
461
+ }
462
+ };
463
+
464
+ // src/idempotency.ts
465
+ function makeIdempotencyKey(runId, stepId) {
466
+ return (scope) => `nagi:${runId}:${stepId}:${scope}`;
467
+ }
468
+ function makeOnce(args) {
469
+ const { runId, stepId, store } = args;
470
+ return async function once(scope, fn) {
471
+ const cached = await store.getOnce(runId, stepId, scope);
472
+ if (cached !== null) return cached;
473
+ const value = await fn();
474
+ await store.recordOnce(runId, stepId, scope, value);
475
+ return value;
476
+ };
477
+ }
478
+
479
+ // src/scheduler.ts
480
+ function nextRunnable(args) {
481
+ const { flow: flow2, runState, input } = args;
482
+ const runnable = [];
483
+ const skip = [];
484
+ for (const [stepId, step] of Object.entries(flow2.steps)) {
485
+ const state = runState.steps[stepId];
486
+ if (state !== void 0 && state.status !== "pending") continue;
487
+ const def = getDef(step);
488
+ const parentGate = checkParentMatch(def, runState);
489
+ if (parentGate === "blocked") continue;
490
+ if (parentGate === "transitive-skip") {
491
+ skip.push({ stepId, reason: "transitive" });
492
+ continue;
493
+ }
494
+ const upstreamCheck = checkUpstream(def, runState);
495
+ if (upstreamCheck === "blocked") continue;
496
+ if (upstreamCheck === "transitive-skip") {
497
+ skip.push({ stepId, reason: "transitive" });
498
+ continue;
499
+ }
500
+ const when = def.kind === "match" ? void 0 : def.when;
501
+ if (when) {
502
+ const needs = resolveNeeds(
503
+ def,
504
+ (id) => runState.steps[id]?.output ?? null
505
+ );
506
+ const shouldRun = when({ input, needs });
507
+ if (!shouldRun) {
508
+ skip.push({ stepId, reason: "when-false" });
509
+ continue;
510
+ }
511
+ }
512
+ runnable.push(stepId);
513
+ }
514
+ return { runnable, skip };
515
+ }
516
+ function checkUpstream(def, runState) {
517
+ for (const upstream of Object.values(def.needs)) {
518
+ if (!upstream || typeof upstream !== "object" || !("id" in upstream) || typeof upstream.id !== "string") {
519
+ continue;
520
+ }
521
+ const upstreamState = runState.steps[upstream.id];
522
+ if (upstreamState === void 0) return "blocked";
523
+ if (upstreamState.status === "completed") continue;
524
+ if (upstreamState.status === "skipped" || upstreamState.status === "failed") {
525
+ return "transitive-skip";
526
+ }
527
+ return "blocked";
528
+ }
529
+ return "ready";
530
+ }
531
+ function checkParentMatch(def, runState) {
532
+ if (!def.parentMatch) return "ready";
533
+ const { matchId, armId } = def.parentMatch;
534
+ const parentState = runState.steps[matchId];
535
+ if (parentState?.status === "failed" || parentState?.status === "skipped") {
536
+ return "transitive-skip";
537
+ }
538
+ const selected = readSelectedArm(matchId, runState);
539
+ if (selected === null) return "blocked";
540
+ return selected === armId ? "ready" : "transitive-skip";
541
+ }
542
+ function aggregateMatch(matchId, flow2, runState) {
543
+ const step = flow2.steps[matchId];
544
+ if (!step) return { kind: "pending" };
545
+ const def = getDef(step);
546
+ if (def.kind !== "match") return { kind: "pending" };
547
+ const selected = readSelectedArm(matchId, runState);
548
+ if (selected === null) return { kind: "pending" };
549
+ const arm = def.mode === "discriminator" ? def.arms[selected] : def.arms.find((a) => a.id === selected);
550
+ if (!arm) return { kind: "pending" };
551
+ const output = {};
552
+ let allTerminal = true;
553
+ const stripPrefix = `${matchId}.${arm.id}.`;
554
+ for (const stepId of arm.stepIds) {
555
+ const state = runState.steps[stepId];
556
+ if (state === void 0 || state.status === "pending" || state.status === "running") {
557
+ allTerminal = false;
558
+ continue;
559
+ }
560
+ if (state.status === "failed") {
561
+ return { kind: "fail-fast", failedStepId: stepId };
562
+ }
563
+ const localKey = stepId.startsWith(stripPrefix) ? stepId.slice(stripPrefix.length) : stepId;
564
+ output[localKey] = state.status === "completed" ? state.output ?? null : null;
565
+ }
566
+ if (!allTerminal) return { kind: "pending" };
567
+ return { kind: "complete", output };
568
+ }
569
+ function flowTermination(flow2, runState) {
570
+ let done = true;
571
+ let failed = false;
572
+ for (const stepId of Object.keys(flow2.steps)) {
573
+ const state = runState.steps[stepId];
574
+ if (state === void 0 || state.status === "pending" || state.status === "running") {
575
+ done = false;
576
+ break;
577
+ }
578
+ if (state.status === "failed") failed = true;
579
+ }
580
+ return { done, failed };
581
+ }
582
+ function extractInput(runState) {
583
+ for (const fact of runState.facts) {
584
+ if (fact.kind === "flow.started") return fact.input;
585
+ }
586
+ throw new Error(
587
+ "No flow.started fact in run \u2014 was the run initialized via wf.start?"
588
+ );
589
+ }
590
+
591
+ // src/dispatch.ts
592
+ var DEFAULT_RETRY = {
593
+ maxAttempts: 3,
594
+ backoff: "exponential",
595
+ initialDelayMs: 1e3,
596
+ maxDelayMs: 6e4
597
+ };
598
+ async function dispatchMessage(deps, message) {
599
+ const { store, queue, clock } = deps;
600
+ const { runId, stepId, attempt } = message;
601
+ const flow2 = await deps.flowFor(runId);
602
+ const step = flow2.steps[stepId];
603
+ if (!step) {
604
+ deps.logger?.warn(
605
+ `dispatch: step "${stepId}" not in flow "${flow2.id}"; ack and skip`
606
+ );
607
+ await queue.ack(message.receipt);
608
+ return;
609
+ }
610
+ const def = getDef(step);
611
+ const preState = await store.loadRunState(runId);
612
+ const preStep = preState.steps[stepId];
613
+ if (preStep && (preStep.status === "completed" || preStep.status === "failed" || preStep.status === "skipped")) {
614
+ await queue.ack(message.receipt);
615
+ return;
616
+ }
617
+ const claim = await store.claimStep(runId, stepId, attempt);
618
+ if (claim === null) {
619
+ await queue.ack(message.receipt);
620
+ return;
621
+ }
622
+ await store.appendFact(runId, {
623
+ kind: "step.started",
624
+ runId,
625
+ stepId,
626
+ attempt,
627
+ at: clock.now()
628
+ });
629
+ const startEventInput = def.kind === "task" ? extractInput(await store.loadRunState(runId)) : null;
630
+ await deps.hooks?.onStepStart?.({
631
+ runId,
632
+ flowId: flow2.id,
633
+ stepId,
634
+ attempt,
635
+ kind: def.kind,
636
+ input: startEventInput,
637
+ at: clock.now()
638
+ });
639
+ const startedAt = Date.now();
640
+ try {
641
+ if (def.kind === "task") {
642
+ const output = await executeTask({
643
+ deps,
644
+ message,
645
+ def,
646
+ runId,
647
+ stepId,
648
+ attempt
649
+ });
650
+ await deps.hooks?.onStepComplete?.({
651
+ runId,
652
+ flowId: flow2.id,
653
+ stepId,
654
+ attempt,
655
+ kind: "task",
656
+ output,
657
+ durationMs: Date.now() - startedAt,
658
+ at: clock.now()
659
+ });
660
+ await advance(deps, runId);
661
+ } else if (def.kind === "signal") {
662
+ await queue.ack(message.receipt);
663
+ return;
664
+ } else if (def.kind === "match") {
665
+ await executeMatch({ deps, message, def, runId, stepId, attempt });
666
+ await advance(deps, runId);
667
+ }
668
+ } catch (err) {
669
+ await handleStepError({
670
+ deps,
671
+ flow: flow2,
672
+ message,
673
+ def,
674
+ runId,
675
+ stepId,
676
+ attempt,
677
+ err
678
+ });
679
+ }
680
+ }
681
+ async function executeTask(args) {
682
+ const { deps, message, def, runId, stepId, attempt } = args;
683
+ if (def.kind !== "task") {
684
+ throw new Error(`executeTask called with non-task def (kind: ${def.kind})`);
685
+ }
686
+ const { store, queue, clock } = deps;
687
+ const runState = await store.loadRunState(runId);
688
+ const input = extractInput(runState);
689
+ const needs = resolveNeeds(def, (id) => runState.steps[id]?.output ?? null);
690
+ const output = await store.runStep(
691
+ runId,
692
+ stepId,
693
+ attempt,
694
+ async (tx) => {
695
+ const ctx = makeStepCtx({
696
+ runId,
697
+ stepId,
698
+ attempt,
699
+ input,
700
+ store,
701
+ clock,
702
+ tx,
703
+ ...deps.logger !== void 0 ? { logger: deps.logger } : {}
704
+ });
705
+ const out = await def.run({ input, needs, ctx });
706
+ const fact = {
707
+ kind: "step.completed",
708
+ runId,
709
+ stepId,
710
+ attempt,
711
+ output: out,
712
+ at: clock.now()
713
+ };
714
+ return { output: out, fact };
715
+ }
716
+ );
717
+ await queue.ack(message.receipt);
718
+ return output;
719
+ }
720
+ async function executeMatch(args) {
721
+ const { deps, message, def, runId, stepId } = args;
722
+ const { store, queue, clock } = deps;
723
+ const runState = await store.loadRunState(runId);
724
+ const input = extractInput(runState);
725
+ const needs = resolveNeeds(def, (id) => runState.steps[id]?.output ?? null);
726
+ const armId = selectArm(def, { input, needs });
727
+ await store.appendFact(runId, {
728
+ kind: "match.arm-selected",
729
+ runId,
730
+ stepId,
731
+ arm: armId,
732
+ at: clock.now()
733
+ });
734
+ await queue.ack(message.receipt);
735
+ }
736
+ async function handleStepError(args) {
737
+ const { deps, flow: flow2, message, def, runId, stepId, attempt, err } = args;
738
+ const { store, queue, clock } = deps;
739
+ const error = serializeError(err);
740
+ const policy = def.kind === "task" && def.retry ? def.retry : deps.defaultRetry ?? DEFAULT_RETRY;
741
+ const shouldRetry = attempt < policy.maxAttempts && retryAllows(policy, err);
742
+ if (shouldRetry) {
743
+ const delayMs = computeBackoff(policy, attempt);
744
+ await store.appendFact(runId, {
745
+ kind: "step.retried",
746
+ runId,
747
+ stepId,
748
+ attempt,
749
+ nextAttemptAt: new Date(Date.now() + delayMs),
750
+ at: clock.now()
751
+ });
752
+ await deps.hooks?.onStepRetry?.({
753
+ runId,
754
+ flowId: flow2.id,
755
+ stepId,
756
+ attempt,
757
+ kind: def.kind,
758
+ error,
759
+ nextAttemptAt: new Date(Date.now() + delayMs),
760
+ at: clock.now()
761
+ });
762
+ await queue.enqueue(runId, stepId, { attempt: attempt + 1, delayMs });
763
+ await queue.ack(message.receipt);
764
+ return;
765
+ }
766
+ const fact = {
767
+ kind: "step.failed",
768
+ runId,
769
+ stepId,
770
+ attempt,
771
+ error,
772
+ at: clock.now()
773
+ };
774
+ await store.failStep(runId, stepId, error, fact);
775
+ await deps.hooks?.onStepError?.({
776
+ runId,
777
+ flowId: flow2.id,
778
+ stepId,
779
+ attempt,
780
+ kind: def.kind,
781
+ error,
782
+ at: clock.now()
783
+ });
784
+ await queue.ack(message.receipt);
785
+ await advance(deps, runId);
786
+ }
787
+ var MAX_ADVANCE_ITERS = 1024;
788
+ async function advance(deps, runId) {
789
+ const { store, queue, clock } = deps;
790
+ const flow2 = await deps.flowFor(runId);
791
+ for (let iter = 0; iter < MAX_ADVANCE_ITERS; iter++) {
792
+ const runState = await store.loadRunState(runId);
793
+ const promoted = await promoteMatches({ deps, flow: flow2, runState });
794
+ if (promoted) continue;
795
+ const termination = flowTermination(flow2, runState);
796
+ if (termination.done) {
797
+ const last2 = runState.facts[runState.facts.length - 1];
798
+ const flowAlreadyTerminal = last2 !== void 0 && (last2.kind === "flow.completed" || last2.kind === "flow.failed");
799
+ if (!flowAlreadyTerminal) {
800
+ if (termination.failed) {
801
+ const failedStep = Object.values(runState.steps).find(
802
+ (s) => s.status === "failed"
803
+ );
804
+ await store.appendFact(runId, {
805
+ kind: "flow.failed",
806
+ runId,
807
+ error: failedStep?.error ?? {
808
+ name: "Error",
809
+ message: "step failed"
810
+ },
811
+ at: clock.now()
812
+ });
813
+ await deps.hooks?.onFlowError?.({
814
+ runId,
815
+ flowId: flow2.id,
816
+ error: failedStep?.error ?? {
817
+ name: "Error",
818
+ message: "step failed"
819
+ },
820
+ at: clock.now()
821
+ });
822
+ } else {
823
+ let flowOutput = null;
824
+ if (flow2.output !== void 0) {
825
+ const stepOutputs = {};
826
+ for (const [sid, sstate] of Object.entries(runState.steps)) {
827
+ if (sstate.output !== void 0) stepOutputs[sid] = sstate.output;
828
+ }
829
+ flowOutput = flow2.output(stepOutputs);
830
+ }
831
+ await store.appendFact(runId, {
832
+ kind: "flow.completed",
833
+ runId,
834
+ output: flowOutput,
835
+ at: clock.now()
836
+ });
837
+ await deps.hooks?.onFlowComplete?.({
838
+ runId,
839
+ flowId: flow2.id,
840
+ output: flowOutput,
841
+ at: clock.now()
842
+ });
843
+ }
844
+ }
845
+ return;
846
+ }
847
+ const input = extractInput(runState);
848
+ const decision = nextRunnable({ flow: flow2, runState, input });
849
+ if (decision.skip.length === 0 && decision.runnable.length === 0) return;
850
+ for (const { stepId, reason } of decision.skip) {
851
+ await store.appendFact(runId, {
852
+ kind: "step.skipped",
853
+ runId,
854
+ stepId,
855
+ reason,
856
+ at: clock.now()
857
+ });
858
+ }
859
+ for (const stepId of decision.runnable) {
860
+ await queue.enqueue(runId, stepId);
861
+ }
862
+ if (decision.runnable.length > 0) {
863
+ return;
864
+ }
865
+ }
866
+ const cycleError = {
867
+ name: "NagiCycleError",
868
+ message: `advance exceeded ${MAX_ADVANCE_ITERS} iterations \u2014 likely a cycle or infinite skip loop in flow "${flow2.id}"`
869
+ };
870
+ const last = (await store.loadRunState(runId)).facts.slice(-1)[0];
871
+ const alreadyTerminal = last !== void 0 && (last.kind === "flow.completed" || last.kind === "flow.failed");
872
+ if (!alreadyTerminal) {
873
+ await store.appendFact(runId, {
874
+ kind: "flow.failed",
875
+ runId,
876
+ error: cycleError,
877
+ at: clock.now()
878
+ });
879
+ await deps.hooks?.onFlowError?.({
880
+ runId,
881
+ flowId: flow2.id,
882
+ error: cycleError,
883
+ at: clock.now()
884
+ });
885
+ }
886
+ }
887
+ async function promoteMatches(args) {
888
+ const { deps, flow: flow2, runState } = args;
889
+ const { store, clock } = deps;
890
+ let promoted = false;
891
+ for (const [matchId, step] of Object.entries(flow2.steps)) {
892
+ const def = getDef(step);
893
+ if (def.kind !== "match") continue;
894
+ const state = runState.steps[matchId];
895
+ if (state?.status !== "running") continue;
896
+ const agg = aggregateMatch(matchId, flow2, runState);
897
+ if (agg.kind === "pending") continue;
898
+ const attempt = state.attempts > 0 ? state.attempts : 1;
899
+ if (agg.kind === "fail-fast") {
900
+ const failedNested = runState.steps[agg.failedStepId];
901
+ const error = failedNested?.error ?? {
902
+ name: "Error",
903
+ message: `match "${matchId}": chosen-arm step "${agg.failedStepId}" failed`
904
+ };
905
+ const fact2 = {
906
+ kind: "step.failed",
907
+ runId: runState.runId,
908
+ stepId: matchId,
909
+ attempt,
910
+ error,
911
+ at: clock.now()
912
+ };
913
+ await store.failStep(runState.runId, matchId, error, fact2);
914
+ await deps.hooks?.onStepError?.({
915
+ runId: runState.runId,
916
+ flowId: flow2.id,
917
+ stepId: matchId,
918
+ attempt,
919
+ kind: "match",
920
+ error,
921
+ at: clock.now()
922
+ });
923
+ promoted = true;
924
+ continue;
925
+ }
926
+ const fact = {
927
+ kind: "step.completed",
928
+ runId: runState.runId,
929
+ stepId: matchId,
930
+ attempt,
931
+ output: agg.output,
932
+ at: clock.now()
933
+ };
934
+ await store.completeStep(runState.runId, matchId, agg.output, fact);
935
+ await deps.hooks?.onStepComplete?.({
936
+ runId: runState.runId,
937
+ flowId: flow2.id,
938
+ stepId: matchId,
939
+ attempt,
940
+ kind: "match",
941
+ output: agg.output,
942
+ durationMs: 0,
943
+ at: clock.now()
944
+ });
945
+ promoted = true;
946
+ }
947
+ return promoted;
948
+ }
949
+ function makeStepCtx(args) {
950
+ const { runId, stepId, attempt, input, store, clock, tx } = args;
951
+ const ac = new AbortController();
952
+ return {
953
+ input,
954
+ runId,
955
+ stepId,
956
+ attempt,
957
+ signal: ac.signal,
958
+ now: () => clock.now(),
959
+ // `tx` is supplied by `Store.runStep` — for adapters with no real
960
+ // transaction (in-memory) it is `undefined as Tx`, and handlers that
961
+ // touch `ctx.tx` will throw on the first call. Adapters that need
962
+ // typed transactional clients (e.g. `@nagi-js/postgres`) augment
963
+ // `Register.tx` and pass a real Kysely transaction here.
964
+ tx,
965
+ logger: args.logger ?? consoleLogger(),
966
+ once: makeOnce({ runId, stepId, store }),
967
+ idempotencyKey: makeIdempotencyKey(runId, stepId)
968
+ };
969
+ }
970
+ function consoleLogger() {
971
+ return {
972
+ debug: (m, a) => console.debug(m, a),
973
+ info: (m, a) => console.info(m, a),
974
+ warn: (m, a) => console.warn(m, a),
975
+ error: (m, a) => console.error(m, a)
976
+ };
977
+ }
978
+ function computeBackoff(policy, attempt) {
979
+ const initial = policy.initialDelayMs ?? 1e3;
980
+ const max = policy.maxDelayMs ?? 6e4;
981
+ switch (policy.backoff) {
982
+ case "exponential":
983
+ return Math.min(initial * 2 ** Math.max(0, attempt - 1), max);
984
+ case "linear":
985
+ return Math.min(initial * Math.max(1, attempt), max);
986
+ case "fixed":
987
+ return Math.min(initial, max);
988
+ }
989
+ }
990
+ function retryAllows(policy, err) {
991
+ if (!policy.retryOn) return true;
992
+ return policy.retryOn(err);
993
+ }
994
+ function serializeError(err) {
995
+ if (err instanceof Error) {
996
+ return {
997
+ name: err.name,
998
+ message: err.message,
999
+ ...err.stack !== void 0 ? { stack: err.stack } : {}
1000
+ };
1001
+ }
1002
+ return { name: "Error", message: String(err) };
1003
+ }
1004
+
1005
+ // src/worker.ts
1006
+ var DEFAULT_CONCURRENCY = 4;
1007
+ var DEFAULT_POLL_INTERVAL_MS = 1e3;
1008
+ function makeWorker(deps, config) {
1009
+ return new WorkerImpl(deps, config);
1010
+ }
1011
+ var WorkerImpl = class {
1012
+ constructor(deps, config) {
1013
+ this.deps = deps;
1014
+ this.concurrency = Math.max(1, config?.concurrency ?? DEFAULT_CONCURRENCY);
1015
+ this.pollIntervalMs = config?.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
1016
+ this.signal = config?.signal;
1017
+ }
1018
+ deps;
1019
+ inFlight = 0;
1020
+ concurrency;
1021
+ pollIntervalMs;
1022
+ signal;
1023
+ async run() {
1024
+ while (!this.aborted()) {
1025
+ const slots = this.concurrency - this.inFlight;
1026
+ if (slots <= 0) {
1027
+ await this.sleep(50);
1028
+ continue;
1029
+ }
1030
+ const messages = await this.dequeue(slots);
1031
+ if (messages.length === 0) {
1032
+ await this.sleep(this.pollIntervalMs);
1033
+ continue;
1034
+ }
1035
+ for (const msg of messages) this.fire(msg);
1036
+ }
1037
+ await this.drain();
1038
+ }
1039
+ async runOnce(opts) {
1040
+ const limit = Math.max(1, opts?.maxSteps ?? this.concurrency);
1041
+ let processed = 0;
1042
+ while (processed < limit && !this.aborted()) {
1043
+ const remaining = limit - processed;
1044
+ const messages = await this.dequeue(
1045
+ Math.min(remaining, this.concurrency)
1046
+ );
1047
+ if (messages.length === 0) break;
1048
+ await Promise.all(messages.map((m) => this.dispatchSafely(m)));
1049
+ processed += messages.length;
1050
+ }
1051
+ return { processed };
1052
+ }
1053
+ async runUntilEmpty(opts) {
1054
+ const deadline = opts?.deadline;
1055
+ let processed = 0;
1056
+ while (!this.aborted()) {
1057
+ if (deadline !== void 0 && Date.now() >= deadline) break;
1058
+ const messages = await this.dequeue(this.concurrency);
1059
+ if (messages.length === 0) break;
1060
+ await Promise.all(messages.map((m) => this.dispatchSafely(m)));
1061
+ processed += messages.length;
1062
+ }
1063
+ return { processed };
1064
+ }
1065
+ async dequeue(count) {
1066
+ return this.deps.queue.dequeue({ count: Math.max(1, count) });
1067
+ }
1068
+ fire(msg) {
1069
+ this.inFlight++;
1070
+ void this.dispatchSafely(msg).finally(() => {
1071
+ this.inFlight = Math.max(0, this.inFlight - 1);
1072
+ });
1073
+ }
1074
+ async dispatchSafely(msg) {
1075
+ try {
1076
+ await dispatchMessage(this.deps, msg);
1077
+ } catch (err) {
1078
+ this.deps.logger?.error("worker.dispatch threw uncaught", {
1079
+ error: String(err)
1080
+ });
1081
+ try {
1082
+ await this.deps.queue.nack(msg.receipt);
1083
+ } catch {
1084
+ }
1085
+ }
1086
+ }
1087
+ async drain() {
1088
+ while (this.inFlight > 0) {
1089
+ await this.deps.clock.sleep(50);
1090
+ }
1091
+ }
1092
+ aborted() {
1093
+ return this.signal?.aborted === true;
1094
+ }
1095
+ async sleep(ms) {
1096
+ try {
1097
+ await this.deps.clock.sleep(ms, this.signal);
1098
+ } catch {
1099
+ }
1100
+ }
1101
+ };
1102
+
1103
+ // src/runtime.ts
1104
+ var NagiValidationError = class extends Error {
1105
+ issues;
1106
+ constructor(issues) {
1107
+ super(issues.map((i) => i.message).join("; "));
1108
+ this.name = "NagiValidationError";
1109
+ this.issues = issues;
1110
+ }
1111
+ };
1112
+ var NagiRuntimeError = class extends Error {
1113
+ constructor(message) {
1114
+ super(message);
1115
+ this.name = "NagiRuntimeError";
1116
+ }
1117
+ };
1118
+ function nagi(config) {
1119
+ const clock = config.clock ?? new InMemoryClock();
1120
+ const flowsById = /* @__PURE__ */ new Map();
1121
+ for (const f of config.flows) {
1122
+ if (flowsById.has(f.id)) {
1123
+ throw new NagiRuntimeError(
1124
+ `Duplicate flow id "${f.id}" passed to nagi()`
1125
+ );
1126
+ }
1127
+ flowsById.set(f.id, f);
1128
+ }
1129
+ async function flowFor(runId) {
1130
+ const runState = await config.store.loadRunState(runId);
1131
+ const flow2 = flowsById.get(runState.flowId);
1132
+ if (!flow2) {
1133
+ throw new NagiRuntimeError(
1134
+ `Run ${runId} references flow "${runState.flowId}" which is not registered with nagi().`
1135
+ );
1136
+ }
1137
+ return flow2;
1138
+ }
1139
+ const dispatchDeps = {
1140
+ flowFor,
1141
+ store: config.store,
1142
+ queue: config.queue,
1143
+ clock,
1144
+ ...config.hooks !== void 0 ? { hooks: config.hooks } : {},
1145
+ ...config.logger !== void 0 ? { logger: config.logger } : {},
1146
+ ...config.defaultRetry !== void 0 ? { defaultRetry: config.defaultRetry } : {}
1147
+ };
1148
+ return {
1149
+ async start(flow2, input, opts) {
1150
+ if (!flowsById.has(flow2.id)) {
1151
+ throw new NagiRuntimeError(
1152
+ `Flow "${flow2.id}" not registered with nagi(). Pass it to flows[].`
1153
+ );
1154
+ }
1155
+ let runId;
1156
+ if (opts?.runId !== void 0) {
1157
+ if (typeof opts.runId !== "string" || opts.runId.length === 0) {
1158
+ throw new NagiValidationError([
1159
+ {
1160
+ message: "opts.runId must be a non-empty string",
1161
+ path: ["runId"]
1162
+ }
1163
+ ]);
1164
+ }
1165
+ runId = opts.runId;
1166
+ } else {
1167
+ runId = mintRunId();
1168
+ }
1169
+ const validated = await validate(flow2.input, input);
1170
+ const startedAt = clock.now();
1171
+ const fact = {
1172
+ kind: "flow.started",
1173
+ runId,
1174
+ flowId: flow2.id,
1175
+ input: validated,
1176
+ at: startedAt
1177
+ };
1178
+ const { started } = await config.store.tryStartRun(runId, fact);
1179
+ if (!started) {
1180
+ return runId;
1181
+ }
1182
+ await config.hooks?.onFlowStart?.({
1183
+ runId,
1184
+ flowId: flow2.id,
1185
+ input: validated,
1186
+ at: startedAt
1187
+ });
1188
+ await advance(dispatchDeps, runId);
1189
+ return runId;
1190
+ },
1191
+ async signal(runId, stepName, payload) {
1192
+ const runState = await config.store.loadRunState(runId);
1193
+ const flow2 = flowsById.get(runState.flowId);
1194
+ if (!flow2) {
1195
+ throw new NagiRuntimeError(
1196
+ `Run ${runId} references flow "${runState.flowId}" which is not registered with nagi().`
1197
+ );
1198
+ }
1199
+ const step = flow2.steps[stepName];
1200
+ if (!step) {
1201
+ throw new NagiRuntimeError(
1202
+ `Flow "${flow2.id}" has no step named "${stepName}".`
1203
+ );
1204
+ }
1205
+ const def = getDef(step);
1206
+ if (def.kind !== "signal") {
1207
+ throw new NagiRuntimeError(
1208
+ `Step "${stepName}" is a ${def.kind}, not a signal.`
1209
+ );
1210
+ }
1211
+ const stepState = runState.steps[stepName];
1212
+ if (stepState?.status !== "running") {
1213
+ throw new NagiRuntimeError(
1214
+ `Step "${stepName}" is not waiting for signal (status: ${stepState?.status ?? "pending"}).`
1215
+ );
1216
+ }
1217
+ const validated = await validate(def.schema, payload);
1218
+ const attempt = stepState.attempts > 0 ? stepState.attempts : 1;
1219
+ await config.store.appendFact(runId, {
1220
+ kind: "signal.received",
1221
+ runId,
1222
+ stepId: stepName,
1223
+ payload: validated,
1224
+ at: clock.now()
1225
+ });
1226
+ const completedFact = {
1227
+ kind: "step.completed",
1228
+ runId,
1229
+ stepId: stepName,
1230
+ attempt,
1231
+ output: validated,
1232
+ at: clock.now()
1233
+ };
1234
+ await config.store.completeStep(
1235
+ runId,
1236
+ stepName,
1237
+ validated,
1238
+ completedFact
1239
+ );
1240
+ await config.hooks?.onSignalReceived?.({
1241
+ runId,
1242
+ flowId: flow2.id,
1243
+ stepId: stepName,
1244
+ attempt,
1245
+ kind: "signal",
1246
+ payload: validated,
1247
+ at: clock.now()
1248
+ });
1249
+ await advance(dispatchDeps, runId);
1250
+ },
1251
+ worker(workerConfig) {
1252
+ if (config.flows.length === 0) {
1253
+ throw new NagiRuntimeError(
1254
+ "nagi(): no flows registered \u2014 cannot create a worker."
1255
+ );
1256
+ }
1257
+ return makeWorker({ ...dispatchDeps, clock }, workerConfig);
1258
+ },
1259
+ async replay(runId, opts = { mode: "continue" }) {
1260
+ const runState = await config.store.loadRunState(runId);
1261
+ const flow2 = flowsById.get(runState.flowId);
1262
+ if (!flow2) {
1263
+ throw new NagiRuntimeError(
1264
+ `Run ${runId} references flow "${runState.flowId}" which is not registered with nagi().`
1265
+ );
1266
+ }
1267
+ if (opts.mode === "inspect") return;
1268
+ await advance(dispatchDeps, runId);
1269
+ }
1270
+ };
1271
+ }
1272
+ function mintRunId() {
1273
+ return `run-${crypto.randomUUID()}`;
1274
+ }
1275
+ async function validate(schema, value) {
1276
+ const result = await schema["~standard"].validate(value);
1277
+ if ("issues" in result && result.issues !== void 0) {
1278
+ throw new NagiValidationError(result.issues);
1279
+ }
1280
+ return result.value;
1281
+ }
1282
+
1283
+ export { InMemoryClock, InMemoryQueue, InMemoryStore, InMemoryTrigger, NagiRuntimeError, NagiValidationError, flow, nagi, projectRunState };
1284
+ //# sourceMappingURL=index.js.map
1285
+ //# sourceMappingURL=index.js.map