replyonce 0.0.0-stage → 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/cli.js ADDED
@@ -0,0 +1,999 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/key.ts
4
+ import { createHash } from "crypto";
5
+ function canonicalJson(value) {
6
+ return JSON.stringify(normalize(value, /* @__PURE__ */ new Set()));
7
+ }
8
+ function normalize(value, seen) {
9
+ if (value === null || value === void 0) return null;
10
+ if (typeof value === "bigint") return value.toString();
11
+ if (typeof value !== "object") {
12
+ if (typeof value === "number" && !Number.isFinite(value)) return String(value);
13
+ if (typeof value === "function" || typeof value === "symbol") return null;
14
+ return value;
15
+ }
16
+ if (value instanceof Date) return value.toISOString();
17
+ if (seen.has(value)) throw new TypeError("replyonce: cannot hash a circular payload");
18
+ seen.add(value);
19
+ let out;
20
+ if (Array.isArray(value)) {
21
+ out = value.map((v) => normalize(v, seen));
22
+ } else {
23
+ const obj = {};
24
+ for (const k of Object.keys(value).sort()) {
25
+ const v = value[k];
26
+ if (v === void 0) continue;
27
+ obj[k] = normalize(v, seen);
28
+ }
29
+ out = obj;
30
+ }
31
+ seen.delete(value);
32
+ return out;
33
+ }
34
+ function sha256Hex(input) {
35
+ return createHash("sha256").update(input).digest("hex");
36
+ }
37
+ function hashPayload(payload) {
38
+ return sha256Hex(canonicalJson(payload)).slice(0, 32);
39
+ }
40
+ var SAFE_INTENT = /^[A-Za-z0-9_.:-]{1,100}$/;
41
+ function effectKey(intent, payload) {
42
+ if (!SAFE_INTENT.test(intent)) {
43
+ throw new TypeError(
44
+ `replyonce: intent must match ${SAFE_INTENT} (got ${JSON.stringify(intent)})`
45
+ );
46
+ }
47
+ return payload === void 0 ? intent : `${intent}:${hashPayload(payload)}`;
48
+ }
49
+ function providerIdempotencyKey(scope, key) {
50
+ return `ro_${sha256Hex(`${scope}\0${key}`).slice(0, 40)}`;
51
+ }
52
+
53
+ // src/replyonce.ts
54
+ import { randomUUID } from "crypto";
55
+
56
+ // src/logic.ts
57
+ var SUMMARY_MAX = 2e3;
58
+ function emptyState() {
59
+ return { v: 1, seq: 0, tokenSeq: 0, runs: {}, effects: {} };
60
+ }
61
+ function load(state, now, policy) {
62
+ const s = state ? structuredClone(state) : emptyState();
63
+ if (s.v !== 1) throw new Error(`replyonce: unsupported state version ${String(s.v)}`);
64
+ const horizon = now - policy.retentionMs;
65
+ for (const [k, e] of Object.entries(s.effects)) {
66
+ if ((e.status === "committed" || e.status === "failed") && (e.settledAt ?? e.claimedAt) < horizon) {
67
+ delete s.effects[k];
68
+ }
69
+ }
70
+ for (const [id, r] of Object.entries(s.runs)) {
71
+ if ((r.endedAt ?? r.leaseExpiresAt) < horizon) delete s.runs[id];
72
+ }
73
+ return s;
74
+ }
75
+ function tick(s) {
76
+ s.seq += 1;
77
+ return s.seq;
78
+ }
79
+ function isAbandoned(e, now, policy) {
80
+ return e.status === "pending" && now - e.claimedAt >= policy.pendingTtlMs;
81
+ }
82
+ function effectiveStatus(e, now, policy) {
83
+ return isAbandoned(e, now, policy) ? "ambiguous" : e.status;
84
+ }
85
+ function isLive(r, now) {
86
+ return r.endedAt === null && r.leaseExpiresAt > now;
87
+ }
88
+ function isNewer(a, b) {
89
+ if (a.mode === "exclusive" && b.mode === "exclusive") return a.token > b.token;
90
+ if (a.orderAt !== b.orderAt) return a.orderAt > b.orderAt;
91
+ if (a.baseContextAt !== null || b.baseContextAt !== null) return false;
92
+ return a.baseSeq > b.baseSeq;
93
+ }
94
+ function supersedingRun(s, run, now) {
95
+ let best = null;
96
+ for (const r of Object.values(s.runs)) {
97
+ if (r.runId === run.runId || r.endReason === "cancelled" || !isNewer(r, run)) continue;
98
+ if (r.wrote || isLive(r, now)) {
99
+ if (!best || isNewer(r, best)) best = r;
100
+ }
101
+ }
102
+ return best;
103
+ }
104
+ function markSeen(run, seq, now) {
105
+ run.seenSeq = Math.max(run.seenSeq, seq);
106
+ run.seenAt = Math.max(run.seenAt, now);
107
+ run.contextAt = null;
108
+ }
109
+ function begin(state, input, policy) {
110
+ const { now } = input;
111
+ const s = load(state, now, policy);
112
+ const existing = s.runs[input.runId];
113
+ if (existing) {
114
+ const priorEffects = Object.values(s.effects).filter((e) => e.runId === input.runId);
115
+ if (existing.endedAt !== null) {
116
+ return { next: null, result: { state: "ended", run: existing, priorEffects } };
117
+ }
118
+ existing.leaseExpiresAt = Math.max(existing.leaseExpiresAt, now + input.leaseTtlMs);
119
+ tick(s);
120
+ s.tokenSeq += 1;
121
+ existing.token = s.tokenSeq;
122
+ existing.seenSeq = existing.baseSeq;
123
+ existing.seenAt = existing.baseContextAt ?? existing.startedAt;
124
+ existing.contextAt = existing.baseContextAt;
125
+ if (input.contextAt !== void 0 && input.contextAt > existing.seenAt) {
126
+ const fresh = Math.min(input.contextAt, now);
127
+ for (const e of Object.values(s.effects)) {
128
+ if (e.settleSeq !== null && e.settledAt !== null && e.settledAt < fresh) {
129
+ existing.seenSeq = Math.max(existing.seenSeq, e.settleSeq);
130
+ }
131
+ }
132
+ existing.seenAt = fresh;
133
+ existing.contextAt = fresh;
134
+ }
135
+ const newer2 = supersedingRun(s, existing, now);
136
+ return { next: s, result: { state: newer2 ? "superseded" : "resumed", run: existing, priorEffects } };
137
+ }
138
+ if (input.mode === "exclusive") {
139
+ let holder;
140
+ for (const r of Object.values(s.runs)) {
141
+ if (isLive(r, now) && (!holder || r.token > holder.token)) holder = r;
142
+ }
143
+ if (holder) {
144
+ return {
145
+ next: null,
146
+ result: {
147
+ state: "busy",
148
+ holder: { runId: holder.runId, token: holder.token, leaseExpiresAt: holder.leaseExpiresAt }
149
+ }
150
+ };
151
+ }
152
+ }
153
+ const seq = tick(s);
154
+ s.tokenSeq += 1;
155
+ const contextAt = input.contextAt === void 0 ? null : Math.min(input.contextAt, now);
156
+ const run = {
157
+ runId: input.runId,
158
+ token: s.tokenSeq,
159
+ mode: input.mode,
160
+ startedAt: now,
161
+ orderAt: input.mode === "exclusive" ? now : contextAt ?? now,
162
+ leaseExpiresAt: now + input.leaseTtlMs,
163
+ seenSeq: seq,
164
+ seenAt: contextAt ?? now,
165
+ contextAt,
166
+ baseSeq: seq,
167
+ baseContextAt: contextAt,
168
+ wrote: false,
169
+ endedAt: null,
170
+ endReason: null
171
+ };
172
+ s.runs[input.runId] = run;
173
+ const newer = supersedingRun(s, run, now);
174
+ return { next: s, result: { state: newer ? "superseded" : "new", run, priorEffects: [] } };
175
+ }
176
+ function renew(state, input, policy) {
177
+ const s = load(state, input.now, policy);
178
+ const r = s.runs[input.runId];
179
+ if (!r) return { next: null, result: { ok: false, reason: "unknown_run" } };
180
+ if (r.token !== input.token) return { next: null, result: { ok: false, reason: "superseded" } };
181
+ if (r.endReason === "cancelled") return { next: null, result: { ok: false, reason: "run_cancelled" } };
182
+ if (r.endedAt !== null) return { next: null, result: { ok: false, reason: "run_ended" } };
183
+ if (supersedingRun(s, r, input.now)) return { next: null, result: { ok: false, reason: "superseded" } };
184
+ r.leaseExpiresAt = Math.max(r.leaseExpiresAt, input.now + input.leaseTtlMs);
185
+ tick(s);
186
+ return { next: s, result: { ok: true, run: r } };
187
+ }
188
+ function endRun(state, input, policy) {
189
+ const s = load(state, input.now, policy);
190
+ const r = s.runs[input.runId];
191
+ if (!r) return { next: null, result: null };
192
+ if (input.token !== void 0 && r.token !== input.token) return { next: null, result: null };
193
+ if (r.endedAt !== null && !(input.reason === "cancelled" && r.endReason !== "cancelled")) {
194
+ return { next: null, result: r };
195
+ }
196
+ r.endedAt = r.endedAt ?? input.now;
197
+ r.endReason = input.reason;
198
+ tick(s);
199
+ return { next: s, result: r };
200
+ }
201
+ function conflictsFor(s, run, key, intent, mode, now, policy) {
202
+ if (mode === "none") return [];
203
+ const out = [];
204
+ for (const e of Object.values(s.effects)) {
205
+ if (e.key === key) continue;
206
+ if (e.runId === run.runId) {
207
+ if (e.token === run.token || e.intent !== intent) continue;
208
+ }
209
+ if (mode === "same-intent" && e.intent !== intent) continue;
210
+ const abandoned = isAbandoned(e, now, policy);
211
+ let conflict = false;
212
+ if (e.status === "pending" && !abandoned) {
213
+ conflict = true;
214
+ } else if (abandoned) {
215
+ conflict = e.claimedAt + policy.pendingTtlMs >= run.seenAt;
216
+ } else if (e.status === "committed" || e.status === "ambiguous") {
217
+ conflict = (e.settleSeq ?? 0) > run.seenSeq || run.contextAt !== null && (e.settledAt ?? 0) >= run.contextAt;
218
+ }
219
+ if (conflict) {
220
+ out.push({
221
+ key: e.key,
222
+ intent: e.intent,
223
+ status: abandoned ? "ambiguous" : e.status,
224
+ runId: e.runId,
225
+ summary: e.summary,
226
+ at: e.settledAt ?? e.claimedAt
227
+ });
228
+ }
229
+ }
230
+ return out.sort((a, b) => a.at - b.at).slice(0, 10);
231
+ }
232
+ function claim(state, scope, input, policy) {
233
+ const { now } = input;
234
+ const s = load(state, now, policy);
235
+ const prior = s.effects[input.key];
236
+ if (prior) {
237
+ if (prior.status === "committed") return { next: null, result: { kind: "replay", entry: prior } };
238
+ if (prior.status === "pending") {
239
+ if (!isAbandoned(prior, now, policy)) {
240
+ return { next: null, result: { kind: "in_flight", entry: prior } };
241
+ }
242
+ const seq2 = tick(s);
243
+ prior.status = "ambiguous";
244
+ prior.settledAt = prior.claimedAt + policy.pendingTtlMs;
245
+ prior.settleSeq = seq2;
246
+ prior.error = prior.error ?? "abandoned: not settled within pendingTtlMs";
247
+ return { next: s, result: { kind: "ambiguous", entry: prior } };
248
+ }
249
+ if (prior.status === "ambiguous") return { next: null, result: { kind: "ambiguous", entry: prior } };
250
+ if (prior.attempt >= policy.maxAttempts) {
251
+ return { next: null, result: { kind: "refused", reason: "exhausted", entry: prior } };
252
+ }
253
+ }
254
+ let run;
255
+ if (input.runId !== null) {
256
+ run = s.runs[input.runId];
257
+ if (!run) return { next: null, result: { kind: "refused", reason: "unknown_run" } };
258
+ if (run.endReason === "cancelled") return { next: null, result: { kind: "refused", reason: "run_cancelled" } };
259
+ if (run.endedAt !== null) return { next: null, result: { kind: "refused", reason: "run_ended" } };
260
+ if (input.token !== null && run.token !== input.token || supersedingRun(s, run, now)) {
261
+ return { next: null, result: { kind: "refused", reason: "superseded" } };
262
+ }
263
+ const conflicts = conflictsFor(s, run, input.key, input.intent, input.conflict, now, policy);
264
+ if (conflicts.length > 0) {
265
+ if (!input.ack) {
266
+ return { next: null, result: { kind: "refused", reason: "conversation_changed", conflicts } };
267
+ }
268
+ markSeen(run, tick(s), now);
269
+ return { next: s, result: { kind: "refused", reason: "conversation_changed", conflicts } };
270
+ }
271
+ }
272
+ const seq = tick(s);
273
+ const entry = {
274
+ key: input.key,
275
+ scope,
276
+ intent: input.intent,
277
+ status: "pending",
278
+ attempt: (prior?.attempt ?? 0) + 1,
279
+ attemptId: input.attemptId,
280
+ runId: input.runId,
281
+ token: run?.token ?? null,
282
+ summary: input.summary === null ? null : input.summary.slice(0, SUMMARY_MAX),
283
+ error: null,
284
+ createdAt: prior?.createdAt ?? now,
285
+ claimedAt: now,
286
+ settledAt: null,
287
+ claimSeq: seq,
288
+ settleSeq: null
289
+ };
290
+ s.effects[input.key] = entry;
291
+ if (run) {
292
+ run.wrote = true;
293
+ run.leaseExpiresAt = Math.max(run.leaseExpiresAt, now + input.leaseTtlMs);
294
+ }
295
+ return { next: s, result: { kind: "acquired", entry } };
296
+ }
297
+ function settle(state, input, policy) {
298
+ const s = load(state, input.now, policy);
299
+ const e = s.effects[input.key];
300
+ const lateTruth = e?.status === "failed" && input.outcome === "committed";
301
+ if (!e || e.attemptId !== input.attemptId || e.status !== "pending" && e.status !== "ambiguous" && !lateTruth) {
302
+ return { next: null, result: { kind: "stale", entry: e ?? null } };
303
+ }
304
+ const wasAbandoned = e.status !== "pending" || isAbandoned(e, input.now, policy);
305
+ e.status = input.outcome;
306
+ e.settledAt = input.now;
307
+ e.settleSeq = tick(s);
308
+ e.error = input.error ?? null;
309
+ if (input.outcome === "committed" && input.result !== void 0) e.result = input.result;
310
+ return { next: s, result: { kind: "settled", entry: e, wasAbandoned } };
311
+ }
312
+ function resolve(state, input, policy) {
313
+ const s = load(state, input.now, policy);
314
+ const e = s.effects[input.key];
315
+ if (!e) return { next: null, result: null };
316
+ if (input.attemptId !== void 0 && e.attemptId !== input.attemptId) return { next: null, result: null };
317
+ if (effectiveStatus(e, input.now, policy) !== "ambiguous") return { next: null, result: null };
318
+ e.settledAt = input.now;
319
+ e.settleSeq = tick(s);
320
+ if (input.outcome === "committed") {
321
+ e.status = "committed";
322
+ e.error = null;
323
+ if (input.result !== void 0) e.result = input.result;
324
+ } else {
325
+ e.status = "failed";
326
+ e.error = "reconciled: not applied";
327
+ }
328
+ return { next: s, result: e };
329
+ }
330
+
331
+ // src/replyonce.ts
332
+ var NotApplied = class extends Error {
333
+ replyonce = "not_applied";
334
+ constructor(message = "effect not applied", options) {
335
+ super(message);
336
+ this.name = "NotApplied";
337
+ if (options && "cause" in options) this.cause = options.cause;
338
+ }
339
+ };
340
+ function defaultClassifyError(err) {
341
+ if (err instanceof NotApplied) return "not_applied";
342
+ if (err && typeof err === "object" && err.replyonce === "not_applied") {
343
+ return "not_applied";
344
+ }
345
+ return "ambiguous";
346
+ }
347
+ var MAX_ROUNDS = 5;
348
+ var ReplyOnce = class {
349
+ store;
350
+ policy;
351
+ leaseTtlMs;
352
+ classify;
353
+ reconciler;
354
+ emit;
355
+ now;
356
+ constructor(options) {
357
+ if (!options || !options.store) throw new TypeError("replyonce: a store is required");
358
+ this.store = options.store;
359
+ this.policy = {
360
+ pendingTtlMs: options.pendingTtlMs ?? 15 * 6e4,
361
+ maxAttempts: options.maxAttempts ?? 3,
362
+ retentionMs: options.retentionMs ?? 7 * 24 * 36e5
363
+ };
364
+ if (this.policy.pendingTtlMs <= 0 || this.policy.maxAttempts < 1 || this.policy.retentionMs <= 0) {
365
+ throw new RangeError("replyonce: pendingTtlMs, maxAttempts and retentionMs must be positive");
366
+ }
367
+ this.leaseTtlMs = options.leaseTtlMs ?? 6e4;
368
+ this.classify = options.classifyError ?? defaultClassifyError;
369
+ this.reconciler = options.reconcile;
370
+ const onEvent = options.onEvent;
371
+ this.emit = onEvent ? (e) => {
372
+ try {
373
+ onEvent(e);
374
+ } catch {
375
+ }
376
+ } : () => {
377
+ };
378
+ this.now = options.clock ?? Date.now;
379
+ }
380
+ /** Resolve key parts to the ledger key used for them. */
381
+ key(parts) {
382
+ return effectKey(parts.intent, parts.payload);
383
+ }
384
+ /**
385
+ * Run `effect` at most once for this key. A string key is its own scope; key parts give
386
+ * a scope (conversation) plus an intent and business identity.
387
+ */
388
+ once(key, effect, options = {}) {
389
+ const { scope, ledgerKey, intent } = this.resolveKey(key);
390
+ return this.execute(scope, ledgerKey, intent, null, effect, { ...options, conflict: "none", ack: false });
391
+ }
392
+ /** "Was this already done?" — safe to expose to a model as a read-only tool. */
393
+ async check(key) {
394
+ const { scope, ledgerKey } = this.resolveKey(key);
395
+ const state = await this.store.read(scope);
396
+ const entry = state?.effects[ledgerKey] ?? null;
397
+ if (!entry) return { done: false, status: "none", entry: null };
398
+ const status = effectiveStatus(entry, this.now(), this.policy);
399
+ return { done: status === "committed", status, entry };
400
+ }
401
+ /** Every effect recorded on a scope, oldest first. */
402
+ async history(scope) {
403
+ const state = await this.store.read(scope);
404
+ if (!state) return [];
405
+ const now = this.now();
406
+ return Object.values(state.effects).map((e) => ({ ...e, status: effectiveStatus(e, now, this.policy) })).sort((a, b) => a.createdAt - b.createdAt);
407
+ }
408
+ /** Record what an operator or reconciler found out about an ambiguous effect. */
409
+ async resolve(key, verdict) {
410
+ const { scope, ledgerKey } = this.resolveKey(key);
411
+ const result = "result" in verdict ? verdict.result : void 0;
412
+ const now = this.now();
413
+ const entry = await this.store.transact(
414
+ scope,
415
+ (s) => resolve(s, { key: ledgerKey, outcome: verdict.outcome, result, now }, this.policy)
416
+ );
417
+ if (entry) this.emit({ type: "reconciled", scope, key: ledgerKey, outcome: verdict.outcome });
418
+ return entry;
419
+ }
420
+ /**
421
+ * Start (or, with the same `runId`, resume) a unit of work on a scope. The run gets a
422
+ * fencing token; its writes are refused once a newer run owns the scope.
423
+ */
424
+ async begin(scope, options = {}) {
425
+ assertScope(scope);
426
+ const runId = options.runId ?? randomUUID();
427
+ const leaseTtlMs = options.leaseTtlMs ?? this.leaseTtlMs;
428
+ const now = this.now();
429
+ const outcome = await this.store.transact(
430
+ scope,
431
+ (s) => begin(
432
+ s,
433
+ {
434
+ runId,
435
+ mode: options.mode ?? "supersede",
436
+ leaseTtlMs,
437
+ now,
438
+ ...options.contextAt !== void 0 ? { contextAt: options.contextAt } : {}
439
+ },
440
+ this.policy
441
+ )
442
+ );
443
+ return new Run(this, scope, runId, leaseTtlMs, outcome);
444
+ }
445
+ /** Cancel a run: its future writes are refused with `run_cancelled`. */
446
+ async cancel(scope, runId) {
447
+ const now = this.now();
448
+ const r = await this.store.transact(
449
+ scope,
450
+ (s) => endRun(s, { runId, reason: "cancelled", now }, this.policy)
451
+ );
452
+ return r !== null;
453
+ }
454
+ /** @internal */
455
+ async renewRun(scope, runId, token, leaseTtlMs) {
456
+ const now = this.now();
457
+ const r = await this.store.transact(
458
+ scope,
459
+ (s) => renew(s, { runId, token, leaseTtlMs, now }, this.policy)
460
+ );
461
+ return r.ok;
462
+ }
463
+ /** @internal */
464
+ async endRun(scope, runId, token, reason) {
465
+ const now = this.now();
466
+ await this.store.transact(scope, (s) => endRun(s, { runId, token, reason, now }, this.policy));
467
+ }
468
+ /** @internal */
469
+ resolveKey(key) {
470
+ if (typeof key === "string") {
471
+ assertScope(key);
472
+ return { scope: key, ledgerKey: key, intent: "once" };
473
+ }
474
+ assertScope(key.scope);
475
+ return { scope: key.scope, ledgerKey: effectKey(key.intent, key.payload), intent: key.intent };
476
+ }
477
+ /** @internal */
478
+ async execute(scope, key, intent, run, effect, options) {
479
+ const runId = run?.runId ?? null;
480
+ const token = run?.token ?? null;
481
+ const classify = options.classifyError ?? this.classify;
482
+ const reconcile = options.reconcile ?? this.reconciler;
483
+ const waitUntil = options.wait ? this.now() + options.wait.timeoutMs : 0;
484
+ let reconciledOnce = false;
485
+ for (let round = 0; round < MAX_ROUNDS; round++) {
486
+ const attemptId = randomUUID();
487
+ const now = this.now();
488
+ const claimed = await this.store.transact(
489
+ scope,
490
+ (s) => claim(
491
+ s,
492
+ scope,
493
+ {
494
+ key,
495
+ intent,
496
+ summary: options.summary ?? null,
497
+ runId,
498
+ token,
499
+ attemptId,
500
+ conflict: options.conflict ?? "any",
501
+ ack: options.ack ?? true,
502
+ leaseTtlMs: run?.leaseTtlMs ?? this.leaseTtlMs,
503
+ now
504
+ },
505
+ this.policy
506
+ )
507
+ );
508
+ switch (claimed.kind) {
509
+ case "replay":
510
+ this.emit({ type: "replayed", scope, key });
511
+ return { status: "replayed", ok: true, value: claimed.entry.result, key, entry: claimed.entry };
512
+ case "refused":
513
+ this.emit({ type: "refused", scope, key, reason: claimed.reason, runId });
514
+ return {
515
+ status: "refused",
516
+ ok: false,
517
+ key,
518
+ reason: claimed.reason,
519
+ ...claimed.conflicts ? { conflicts: claimed.conflicts } : {},
520
+ ...claimed.entry ? { entry: claimed.entry } : {}
521
+ };
522
+ case "in_flight": {
523
+ if (options.wait && this.now() < waitUntil) {
524
+ await this.waitForSettle(scope, key, waitUntil, options.wait.intervalMs ?? 100);
525
+ continue;
526
+ }
527
+ this.emit({ type: "in_flight", scope, key });
528
+ return { status: "in_flight", ok: false, key, entry: claimed.entry };
529
+ }
530
+ case "ambiguous": {
531
+ if (reconcile && !reconciledOnce) {
532
+ reconciledOnce = true;
533
+ let verdict;
534
+ try {
535
+ verdict = await reconcile(claimed.entry);
536
+ } catch {
537
+ verdict = { outcome: "unknown" };
538
+ }
539
+ if (verdict.outcome !== "unknown") {
540
+ const at = this.now();
541
+ const resolved = await this.store.transact(
542
+ scope,
543
+ (s) => resolve(
544
+ s,
545
+ {
546
+ key,
547
+ outcome: verdict.outcome,
548
+ result: verdict.outcome === "committed" ? verdict.result : void 0,
549
+ attemptId: claimed.entry.attemptId,
550
+ now: at
551
+ },
552
+ this.policy
553
+ )
554
+ );
555
+ if (resolved) this.emit({ type: "reconciled", scope, key, outcome: verdict.outcome });
556
+ continue;
557
+ }
558
+ }
559
+ this.emit({ type: "ambiguous", scope, key });
560
+ return { status: "ambiguous", ok: false, key, entry: claimed.entry };
561
+ }
562
+ case "acquired":
563
+ return this.runAcquired(scope, key, claimed.entry, effect, classify);
564
+ }
565
+ }
566
+ const entry = (await this.store.read(scope))?.effects[key];
567
+ if (entry && entry.status === "committed") {
568
+ return { status: "replayed", ok: true, value: entry.result, key, entry };
569
+ }
570
+ if (entry) {
571
+ this.emit({ type: "in_flight", scope, key });
572
+ return { status: "in_flight", ok: false, key, entry };
573
+ }
574
+ throw new Error("replyonce: could not claim key after several rounds");
575
+ }
576
+ async runAcquired(scope, key, entry, effect, classify) {
577
+ const { attempt, attemptId, runId } = entry;
578
+ this.emit({ type: "claimed", scope, key, attempt, runId });
579
+ const remaining = Math.max(1, entry.claimedAt + this.policy.pendingTtlMs - this.now());
580
+ const ctx = {
581
+ key,
582
+ scope,
583
+ intent: entry.intent,
584
+ attempt,
585
+ idempotencyKey: providerIdempotencyKey(scope, key),
586
+ fencingToken: entry.token,
587
+ signal: AbortSignal.timeout(remaining)
588
+ };
589
+ let value;
590
+ try {
591
+ value = await effect(ctx);
592
+ } catch (error) {
593
+ const cls = safeClassify(classify, error);
594
+ const outcome = cls === "not_applied" ? "failed" : "ambiguous";
595
+ await this.settleWithRetry(scope, {
596
+ key,
597
+ attemptId,
598
+ outcome,
599
+ error: describeError(error),
600
+ now: 0
601
+ }).catch(() => void 0);
602
+ if (outcome === "failed") {
603
+ this.emit({ type: "failed", scope, key, attempt, error });
604
+ return {
605
+ status: "failed",
606
+ ok: false,
607
+ key,
608
+ attempt,
609
+ error,
610
+ retryable: attempt < this.policy.maxAttempts
611
+ };
612
+ }
613
+ this.emit({ type: "ambiguous", scope, key, error });
614
+ const current = (await this.store.read(scope).catch(() => null))?.effects[key] ?? entry;
615
+ return { status: "ambiguous", ok: false, key, entry: current, error };
616
+ }
617
+ let stored;
618
+ try {
619
+ stored = value === void 0 ? null : JSON.parse(JSON.stringify(value));
620
+ } catch {
621
+ stored = null;
622
+ this.emit({ type: "unserializable_result", scope, key });
623
+ }
624
+ try {
625
+ const settled = await this.settleWithRetry(scope, {
626
+ key,
627
+ attemptId,
628
+ outcome: "committed",
629
+ result: stored,
630
+ now: 0
631
+ });
632
+ this.emit({ type: "committed", scope, key, attempt, runId });
633
+ if (settled.kind === "stale" || settled.wasAbandoned) {
634
+ this.emit({ type: "late_settle", scope, key, attempt });
635
+ return { status: "executed", ok: true, value, key, attempt, lateSettle: true };
636
+ }
637
+ } catch {
638
+ this.emit({ type: "late_settle", scope, key, attempt });
639
+ return { status: "executed", ok: true, value, key, attempt, lateSettle: true };
640
+ }
641
+ return { status: "executed", ok: true, value, key, attempt };
642
+ }
643
+ async settleWithRetry(scope, input) {
644
+ let lastError;
645
+ for (let i = 0; i < 4; i++) {
646
+ try {
647
+ const now = this.now();
648
+ return await this.store.transact(scope, (s) => settle(s, { ...input, now }, this.policy));
649
+ } catch (err) {
650
+ lastError = err;
651
+ await sleep(25 * 4 ** i);
652
+ }
653
+ }
654
+ throw lastError;
655
+ }
656
+ async waitForSettle(scope, key, until, intervalMs) {
657
+ while (this.now() < until) {
658
+ await sleep(Math.min(intervalMs, Math.max(1, until - this.now())));
659
+ const e = (await this.store.read(scope))?.effects[key];
660
+ if (!e || effectiveStatus(e, this.now(), this.policy) !== "pending") return;
661
+ }
662
+ }
663
+ };
664
+ var Run = class {
665
+ scope;
666
+ runId;
667
+ /** `new`, `resumed` (same runId seen before), `superseded`, `ended`, or `busy` (exclusive mode). */
668
+ state;
669
+ /** Monotonic per scope. `null` when the run could not start (`busy`). */
670
+ fencingToken;
671
+ /** Effects this runId already claimed before (non-empty on redelivery). */
672
+ priorEffects;
673
+ busyHolder;
674
+ ro;
675
+ leaseTtlMs;
676
+ constructor(ro, scope, runId, leaseTtlMs, outcome) {
677
+ this.ro = ro;
678
+ this.scope = scope;
679
+ this.runId = runId;
680
+ this.leaseTtlMs = leaseTtlMs;
681
+ this.state = outcome.state;
682
+ if (outcome.state === "busy") {
683
+ this.fencingToken = null;
684
+ this.priorEffects = [];
685
+ this.busyHolder = outcome.holder;
686
+ } else {
687
+ this.fencingToken = outcome.run.token;
688
+ this.priorEffects = outcome.priorEffects;
689
+ this.busyHolder = null;
690
+ }
691
+ }
692
+ /** `true` when this run may write: it is new or resumed, not superseded, ended or busy. */
693
+ get canWrite() {
694
+ return this.state === "new" || this.state === "resumed";
695
+ }
696
+ /**
697
+ * `true` when a previous delivery of this run already claimed a write that may have
698
+ * reached the outside world (pending, committed or ambiguous).
699
+ */
700
+ get alreadyWrote() {
701
+ return this.priorEffects.some((e) => e.status !== "failed");
702
+ }
703
+ /** Run `effect` at most once per (scope, intent, payload), fenced to this run. */
704
+ once(intent, payload, effect, options = {}) {
705
+ const key = effectKey(intent, payload);
706
+ if (this.fencingToken === null) {
707
+ return Promise.resolve({ status: "refused", ok: false, key, reason: "busy" });
708
+ }
709
+ const self = { runId: this.runId, token: this.fencingToken, leaseTtlMs: this.leaseTtlMs };
710
+ return this.ro.execute(this.scope, key, intent, self, effect, options);
711
+ }
712
+ check(intent, payload) {
713
+ return this.ro.check({ scope: this.scope, intent, payload });
714
+ }
715
+ history() {
716
+ return this.ro.history(this.scope);
717
+ }
718
+ /**
719
+ * Renew the lease. `false` means stop: the run was superseded (by a newer run or a later
720
+ * delivery of this one), cancelled or ended.
721
+ */
722
+ heartbeat() {
723
+ if (this.fencingToken === null) return Promise.resolve(false);
724
+ return this.ro.renewRun(this.scope, this.runId, this.fencingToken, this.leaseTtlMs);
725
+ }
726
+ /** Mark the run finished. A no-op if a later delivery of the same run has taken over. */
727
+ end(reason = "completed") {
728
+ if (this.fencingToken === null) return Promise.resolve();
729
+ return this.ro.endRun(this.scope, this.runId, this.fencingToken, reason);
730
+ }
731
+ };
732
+ function createReplyOnce(options) {
733
+ return new ReplyOnce(options);
734
+ }
735
+ function assertScope(scope) {
736
+ if (typeof scope !== "string" || scope.length === 0 || scope.length > 512) {
737
+ throw new TypeError("replyonce: scope must be a non-empty string of at most 512 characters");
738
+ }
739
+ }
740
+ function safeClassify(classify, err) {
741
+ try {
742
+ return classify(err) === "not_applied" ? "not_applied" : "ambiguous";
743
+ } catch {
744
+ return "ambiguous";
745
+ }
746
+ }
747
+ function describeError(err) {
748
+ const text = err instanceof Error ? `${err.name}: ${err.message}` : String(err);
749
+ return text.slice(0, 500);
750
+ }
751
+ function sleep(ms) {
752
+ return new Promise((r) => setTimeout(r, ms));
753
+ }
754
+
755
+ // src/stores/memory.ts
756
+ var MemoryStore = class {
757
+ data = /* @__PURE__ */ new Map();
758
+ async transact(scope, fn) {
759
+ const raw = this.data.get(scope);
760
+ const { next, result } = fn(raw === void 0 ? null : JSON.parse(raw));
761
+ if (next) this.data.set(scope, JSON.stringify(next));
762
+ return result;
763
+ }
764
+ async read(scope) {
765
+ const raw = this.data.get(scope);
766
+ return raw === void 0 ? null : JSON.parse(raw);
767
+ }
768
+ /** Number of scopes held. */
769
+ get size() {
770
+ return this.data.size;
771
+ }
772
+ clear() {
773
+ this.data.clear();
774
+ }
775
+ };
776
+
777
+ // src/demo.ts
778
+ var TimeoutError = class extends Error {
779
+ constructor() {
780
+ super("provider timed out");
781
+ this.name = "TimeoutError";
782
+ }
783
+ };
784
+ function rng(seed) {
785
+ let a = seed >>> 0;
786
+ return () => {
787
+ a = a + 1831565813 >>> 0;
788
+ let t = a;
789
+ t = Math.imul(t ^ t >>> 15, t | 1);
790
+ t ^= t + Math.imul(t ^ t >>> 7, t | 61);
791
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
792
+ };
793
+ }
794
+ var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
795
+ var hang = () => new Promise(() => {
796
+ });
797
+ function answered(world, conv) {
798
+ const s = /* @__PURE__ */ new Set();
799
+ for (const r of world.sent) if (r.conv === conv) r.answers.forEach((a) => s.add(a));
800
+ return s;
801
+ }
802
+ async function providerSend(world, reply) {
803
+ await sleep2(1 + world.rand() * 4);
804
+ const roll = world.rand();
805
+ if (roll < 0.15) {
806
+ world.sent.push(reply);
807
+ world.faults.timeoutAfterSend = (world.faults.timeoutAfterSend ?? 0) + 1;
808
+ throw new TimeoutError();
809
+ }
810
+ if (roll < 0.25) {
811
+ world.faults.timeoutBeforeSend = (world.faults.timeoutBeforeSend ?? 0) + 1;
812
+ throw new TimeoutError();
813
+ }
814
+ world.sent.push(reply);
815
+ }
816
+ function naiveAgent(world) {
817
+ return async (job, crash) => {
818
+ await sleep2(2 + world.rand() * 10);
819
+ const todo = job.inboundAtEnqueue.filter((m) => !job.answeredAtEnqueue.has(m));
820
+ if (todo.length === 0) return;
821
+ for (let attempt = 0; attempt < 5; attempt++) {
822
+ try {
823
+ await providerSend(world, { conv: job.conv, answers: todo, idem: null });
824
+ if (crash) await hang();
825
+ return;
826
+ } catch {
827
+ }
828
+ }
829
+ };
830
+ }
831
+ function guardedAgent(world, store) {
832
+ const ro = createReplyOnce({
833
+ store,
834
+ pendingTtlMs: 2e3,
835
+ maxAttempts: 6,
836
+ leaseTtlMs: 5e3,
837
+ // A settle after the claim was given up on means pendingTtlMs was too short for this host.
838
+ onEvent: (e) => {
839
+ if (e.type === "late_settle") world.faults.lateSettle = (world.faults.lateSettle ?? 0) + 1;
840
+ },
841
+ // Look the message up on the provider by the idempotency key it was sent with.
842
+ reconcile: async (entry) => {
843
+ const idem = providerIdempotencyKey(entry.scope, entry.key);
844
+ return world.sent.some((r) => r.idem === idem) ? { outcome: "committed" } : { outcome: "not_applied" };
845
+ }
846
+ });
847
+ return async (job, crash) => {
848
+ await sleep2(2 + world.rand() * 10);
849
+ const run = await ro.begin(job.conv, { runId: job.id, contextAt: job.contextAt });
850
+ if (!run.canWrite) return;
851
+ let inbound = job.inboundAtEnqueue;
852
+ let done = job.answeredAtEnqueue;
853
+ const giveUpAt = Date.now() + 15e3;
854
+ while (Date.now() < giveUpAt) {
855
+ const todo = inbound.filter((m) => !done.has(m));
856
+ if (todo.length === 0) break;
857
+ const identity = { answers: todo };
858
+ const res = await run.once(
859
+ "reply",
860
+ identity,
861
+ async (ctx) => {
862
+ await providerSend(world, { conv: job.conv, answers: todo, idem: ctx.idempotencyKey });
863
+ if (crash) await hang();
864
+ },
865
+ { summary: `reply to ${todo.join(", ")}`, wait: { timeoutMs: 5e3, intervalMs: 10 } }
866
+ );
867
+ if (res.ok) break;
868
+ if (res.status === "refused" && res.reason === "conversation_changed") {
869
+ await sleep2(res.conflicts?.some((c) => c.status === "pending") ? 25 : 5);
870
+ inbound = world.inbound.get(job.conv) ?? [];
871
+ done = answered(world, job.conv);
872
+ continue;
873
+ }
874
+ if (res.status === "refused") break;
875
+ await sleep2(10);
876
+ }
877
+ await run.end();
878
+ };
879
+ }
880
+ async function simulate(conversations, seed, makeAgent) {
881
+ const world = { inbound: /* @__PURE__ */ new Map(), sent: [], faults: {}, rand: rng(seed) };
882
+ const agent = makeAgent(world);
883
+ const tasks = [];
884
+ let jobSeq = 0;
885
+ const deliver = (job) => {
886
+ const r = world.rand();
887
+ const crash = r < 0.1;
888
+ const duplicate = r >= 0.1 && r < 0.25;
889
+ if (crash) {
890
+ world.faults.workerCrash = (world.faults.workerCrash ?? 0) + 1;
891
+ void agent(job, true);
892
+ tasks.push(sleep2(60 + world.rand() * 60).then(() => agent(job, false)));
893
+ return;
894
+ }
895
+ tasks.push(agent(job, false));
896
+ if (duplicate) {
897
+ world.faults.duplicateDelivery = (world.faults.duplicateDelivery ?? 0) + 1;
898
+ tasks.push(sleep2(world.rand() * 8).then(() => agent(job, false)));
899
+ }
900
+ };
901
+ const enqueue = (conv, msgId) => {
902
+ const list = world.inbound.get(conv) ?? [];
903
+ list.push(msgId);
904
+ world.inbound.set(conv, list);
905
+ deliver({
906
+ id: `job-${++jobSeq}`,
907
+ conv,
908
+ msgId,
909
+ answeredAtEnqueue: answered(world, conv),
910
+ inboundAtEnqueue: [...list],
911
+ contextAt: Date.now()
912
+ });
913
+ };
914
+ const ns = Math.random().toString(36).slice(2, 10);
915
+ for (let i = 0; i < conversations; i++) {
916
+ const conv = `demo-${ns}-conversation-${i}`;
917
+ enqueue(conv, `${conv}/m1`);
918
+ if (world.rand() < 0.25) {
919
+ world.faults.customerBurst = (world.faults.customerBurst ?? 0) + 1;
920
+ tasks.push(sleep2(1 + world.rand() * 12).then(() => enqueue(conv, `${conv}/m2`)));
921
+ }
922
+ }
923
+ for (let n = 0; n !== tasks.length; ) {
924
+ n = tasks.length;
925
+ await Promise.all(tasks.slice());
926
+ }
927
+ await sleep2(50);
928
+ let duplicateAnswers = 0;
929
+ let unanswered = 0;
930
+ let customerMessages = 0;
931
+ for (const [conv, msgs] of world.inbound) {
932
+ for (const m of msgs) {
933
+ customerMessages++;
934
+ const count = world.sent.filter((r) => r.conv === conv && r.answers.includes(m)).length;
935
+ if (count === 0) unanswered++;
936
+ if (count > 1) duplicateAnswers += count - 1;
937
+ }
938
+ }
939
+ return {
940
+ conversations,
941
+ customerMessages,
942
+ repliesSent: world.sent.length,
943
+ duplicateAnswers,
944
+ unanswered,
945
+ faults: world.faults
946
+ };
947
+ }
948
+ async function runDemo(options = {}) {
949
+ const conversations = options.conversations ?? 100;
950
+ const seed = options.seed ?? 7;
951
+ const { faults: withoutFaults, ...without } = await simulate(conversations, seed, naiveAgent);
952
+ const store = options.store ?? new MemoryStore();
953
+ const { faults: withFaults, ...guarded } = await simulate(conversations, seed, (w) => guardedAgent(w, store));
954
+ return { without, with: guarded, faults: { without: withoutFaults, with: withFaults } };
955
+ }
956
+ function formatReport(r) {
957
+ const row = (label, s) => ` ${label.padEnd(16)} ${String(s.customerMessages).padStart(8)} ${String(s.repliesSent).padStart(7)} ${String(s.duplicateAnswers).padStart(10)} ${String(s.unanswered).padStart(11)}`;
958
+ const faults = (f) => Object.entries(f).map(([k, v]) => `${k}=${v}`).join(", ");
959
+ return [
960
+ `replyonce demo: ${r.without.conversations} simulated support conversations under injected faults`,
961
+ ` without: ${faults(r.faults.without)}`,
962
+ ` with: ${faults(r.faults.with)}`,
963
+ "",
964
+ ` ${"".padEnd(16)} ${"messages".padStart(8)} ${"replies".padStart(7)} ${"duplicates".padStart(10)} ${"unanswered".padStart(11)}`,
965
+ row("without", r.without),
966
+ row("with replyonce", r.with),
967
+ "",
968
+ r.with.duplicateAnswers === 0 ? " No customer message was answered twice with replyonce." : r.faults.with.lateSettle ? " Duplicates happened after this host stalled past pendingTtlMs (see lateSettle): the limit described in the README." : " Duplicates remained with replyonce. Please report this: https://github.com/shmindmaster/replyonce/issues"
969
+ ].join("\n");
970
+ }
971
+
972
+ // src/cli.ts
973
+ var HELP = `Usage: replyonce demo [--conversations N] [--seed N]
974
+
975
+ Runs a chaos simulation of a support agent with and without replyonce and prints
976
+ how many customer messages were answered twice.`;
977
+ async function main(argv) {
978
+ const [command, ...rest] = argv;
979
+ if (command !== "demo") {
980
+ console.log(HELP);
981
+ return command === void 0 || command === "--help" || command === "-h" ? 0 : 1;
982
+ }
983
+ const opt = (name, fallback, min) => {
984
+ const i = rest.indexOf(`--${name}`);
985
+ const v = i >= 0 ? Number(rest[i + 1]) : fallback;
986
+ if (!Number.isInteger(v) || v < min) throw new Error(`--${name} must be an integer >= ${min}`);
987
+ return v;
988
+ };
989
+ const report = await runDemo({ conversations: opt("conversations", 100, 1), seed: opt("seed", 7, 0) });
990
+ console.log(formatReport(report));
991
+ return report.with.duplicateAnswers === 0 ? 0 : 2;
992
+ }
993
+ main(process.argv.slice(2)).then(
994
+ (code) => process.exit(code),
995
+ (err) => {
996
+ console.error(err instanceof Error ? err.message : err);
997
+ process.exit(1);
998
+ }
999
+ );