@rotorsoft/act 0.45.0 → 0.46.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 CHANGED
@@ -19,7 +19,7 @@ import {
19
19
  sleep,
20
20
  store,
21
21
  validate
22
- } from "./chunk-PGTC7VOC.js";
22
+ } from "./chunk-VC6MSVC3.js";
23
23
  import {
24
24
  ActorSchema,
25
25
  CausationEventSchema,
@@ -36,7 +36,7 @@ import {
36
36
  TargetSchema,
37
37
  ValidationError,
38
38
  ZodEmpty
39
- } from "./chunk-VMX7RPTC.js";
39
+ } from "./chunk-TZWDSNSN.js";
40
40
  import "./chunk-5WRI5ZAA.js";
41
41
 
42
42
  // src/signals.ts
@@ -60,6 +60,425 @@ process.once("unhandledRejection", async (arg) => {
60
60
  // src/act.ts
61
61
  import EventEmitter from "events";
62
62
 
63
+ // src/internal/event-versions.ts
64
+ var VERSION_SUFFIX = /^(.+?)_v(\d+)$/;
65
+ function parse(name) {
66
+ const m = name.match(VERSION_SUFFIX);
67
+ if (m) {
68
+ const v = Number.parseInt(m[2], 10);
69
+ if (v >= 2) return { base: m[1], version: v };
70
+ }
71
+ return { base: name, version: 1 };
72
+ }
73
+ function deprecatedEventNames(names) {
74
+ const groups = /* @__PURE__ */ new Map();
75
+ for (const name of names) {
76
+ const { base, version } = parse(name);
77
+ const list = groups.get(base);
78
+ if (list) list.push({ version, name });
79
+ else groups.set(base, [{ version, name }]);
80
+ }
81
+ const deprecated = /* @__PURE__ */ new Set();
82
+ for (const list of groups.values()) {
83
+ if (list.length < 2) continue;
84
+ list.sort((a, b) => b.version - a.version);
85
+ for (let i = 1; i < list.length; i++) deprecated.add(list[i].name);
86
+ }
87
+ return deprecated;
88
+ }
89
+ function currentVersionOf(deprecatedName, allNames) {
90
+ const target = parse(deprecatedName);
91
+ let highest;
92
+ for (const name of allNames) {
93
+ const { base, version } = parse(name);
94
+ if (base !== target.base) continue;
95
+ if (!highest || version > highest.version) highest = { version, name };
96
+ }
97
+ return highest && highest.version > target.version ? highest.name : void 0;
98
+ }
99
+
100
+ // src/internal/audit.ts
101
+ var DEFAULTS = {
102
+ idle_days: 90,
103
+ restart_min: 1e4,
104
+ stuck_minutes: 30,
105
+ deprecated_min: 0.1,
106
+ drift_min: 500,
107
+ near_block: 3
108
+ };
109
+ var ALL_CATEGORIES = [
110
+ "schema",
111
+ "close-candidate",
112
+ "restart-candidate",
113
+ "deprecated-load",
114
+ "reaction-health",
115
+ "snapshot-drift",
116
+ "routing-health",
117
+ "correlation-gaps",
118
+ "clock-anomalies"
119
+ ];
120
+ async function* audit(deps, categories, options = {}) {
121
+ const requested = new Set(categories ?? [...ALL_CATEGORIES]);
122
+ const orderedCategories = ALL_CATEGORIES.filter((c) => requested.has(c));
123
+ const passes = orderedCategories.map(
124
+ (c) => PASS_FACTORIES[c](deps, options)
125
+ );
126
+ const needStats = passes.some((p) => p.onStat !== void 0);
127
+ const needStreams = passes.some((p) => p.onStream !== void 0);
128
+ const needEvents = passes.some((p) => p.onEvent !== void 0);
129
+ if (needStats) {
130
+ const stats = await deps.store().query_stats({}, { count: true, names: true });
131
+ for (const [stream, s] of stats) {
132
+ for (const p of passes) p.onStat?.(stream, s);
133
+ }
134
+ }
135
+ if (needStreams) {
136
+ await deps.store().query_streams((pos) => {
137
+ for (const p of passes) p.onStream?.(pos);
138
+ });
139
+ }
140
+ if (needEvents) {
141
+ await deps.store().query((event) => {
142
+ for (const p of passes) p.onEvent?.(event);
143
+ }, options.query);
144
+ }
145
+ for (const p of passes) await p.finalize?.(deps);
146
+ for (const p of passes) {
147
+ for (const f of p.drain()) yield f;
148
+ }
149
+ }
150
+ var makeSchemaPass = (deps) => {
151
+ const findings = [];
152
+ return {
153
+ category: "schema",
154
+ onEvent(event) {
155
+ const name = String(event.name);
156
+ const state2 = deps.event_to_state.get(name);
157
+ if (!state2) {
158
+ if (name.startsWith("__")) return;
159
+ findings.push({
160
+ category: "schema",
161
+ stream: event.stream,
162
+ event_id: event.id,
163
+ name,
164
+ reason: "unknown_event_name"
165
+ });
166
+ return;
167
+ }
168
+ const schema = state2.events[name];
169
+ const parsed = schema.safeParse(event.data);
170
+ if (!parsed.success) {
171
+ findings.push({
172
+ category: "schema",
173
+ stream: event.stream,
174
+ event_id: event.id,
175
+ name,
176
+ reason: "schema_validation_failed",
177
+ zod_error: parsed.error
178
+ });
179
+ }
180
+ },
181
+ drain: () => findings
182
+ };
183
+ };
184
+ var makeDeprecatedLoadPass = (deps, options) => {
185
+ const share_min = options.thresholds?.deprecated_min ?? DEFAULTS.deprecated_min;
186
+ const totals = /* @__PURE__ */ new Map();
187
+ const perStream = /* @__PURE__ */ new Map();
188
+ return {
189
+ category: "deprecated-load",
190
+ onStat(stream, { names }) {
191
+ for (const [name, count] of Object.entries(names)) {
192
+ totals.set(name, (totals.get(name) ?? 0) + count);
193
+ let m = perStream.get(name);
194
+ if (!m) {
195
+ m = /* @__PURE__ */ new Map();
196
+ perStream.set(name, m);
197
+ }
198
+ m.set(stream, count);
199
+ }
200
+ },
201
+ drain() {
202
+ const findings = [];
203
+ const grand = [...totals.values()].reduce((s, n) => s + n, 0);
204
+ if (grand === 0) return findings;
205
+ const deprecated = deprecatedEventNames(deps.known_events);
206
+ const sorted = [...deprecated].map((name) => ({ name, count: totals.get(name) ?? 0 })).sort((a, b) => b.count - a.count);
207
+ for (const { name, count } of sorted) {
208
+ if (count === 0) continue;
209
+ if (count / grand < share_min) continue;
210
+ const currentVersion = currentVersionOf(name, deps.known_events);
211
+ const topStreams = [...perStream.get(name).entries()].map(([stream, c]) => ({ stream, count: c })).sort((a, b) => b.count - a.count).slice(0, 10);
212
+ findings.push({
213
+ category: "deprecated-load",
214
+ name,
215
+ current_version: currentVersion,
216
+ total: count,
217
+ top_streams: topStreams
218
+ });
219
+ }
220
+ return findings;
221
+ }
222
+ };
223
+ };
224
+ var makeCloseCandidatePass = (deps, options) => {
225
+ const idle_days = options.thresholds?.idle_days ?? DEFAULTS.idle_days;
226
+ const terminal_events = new Set(options.thresholds?.terminal_events ?? []);
227
+ const idle_cutoff = Date.now() - idle_days * 24 * 60 * 60 * 1e3;
228
+ const findings = [];
229
+ return {
230
+ category: "close-candidate",
231
+ onStat(stream, { head }) {
232
+ const head_name = String(head.name);
233
+ if (head_name.startsWith("__")) return;
234
+ const head_time = head.created.getTime();
235
+ const is_idle = head_time < idle_cutoff;
236
+ const is_terminal = terminal_events.has(head_name);
237
+ if (!is_idle && !is_terminal) return;
238
+ findings.push({
239
+ category: "close-candidate",
240
+ stream,
241
+ last_event_at: head.created.toISOString(),
242
+ reason: is_terminal ? "terminal" : "idle",
243
+ idle_days: is_idle ? Math.floor((Date.now() - head_time) / (24 * 60 * 60 * 1e3)) : void 0,
244
+ restart_supported: restartIsSupported(deps, head_name)
245
+ });
246
+ },
247
+ drain: () => findings
248
+ };
249
+ };
250
+ var makeRestartCandidatePass = (deps, options) => {
251
+ const threshold = options.thresholds?.restart_min ?? DEFAULTS.restart_min;
252
+ const findings = [];
253
+ return {
254
+ category: "restart-candidate",
255
+ onStat(stream, { head, count, names }) {
256
+ if (count < threshold) return;
257
+ const head_name = String(head.name);
258
+ if (head_name.startsWith("__")) return;
259
+ if (!restartIsSupported(deps, head_name)) return;
260
+ findings.push({
261
+ category: "restart-candidate",
262
+ stream,
263
+ count,
264
+ // names map is sparse — `__snapshot__` key absent when the
265
+ // stream has never been snapshotted (a common case for the
266
+ // restart-candidate signal).
267
+ snaps: names["__snapshot__"] ?? 0
268
+ });
269
+ },
270
+ drain: () => findings
271
+ };
272
+ };
273
+ var makeReactionHealthPass = (_deps, options) => {
274
+ const near_block = options.thresholds?.near_block ?? DEFAULTS.near_block;
275
+ const stuck_minutes = options.thresholds?.stuck_minutes ?? DEFAULTS.stuck_minutes;
276
+ const stuck_cutoff = Date.now() - stuck_minutes * 60 * 1e3;
277
+ const findings = [];
278
+ return {
279
+ category: "reaction-health",
280
+ onStream(p) {
281
+ if (p.blocked) {
282
+ findings.push({
283
+ category: "reaction-health",
284
+ stream: p.stream,
285
+ status: "blocked",
286
+ retry: p.retry,
287
+ reason: p.error || "blocked without recorded error"
288
+ });
289
+ return;
290
+ }
291
+ if (p.retry >= near_block) {
292
+ findings.push({
293
+ category: "reaction-health",
294
+ stream: p.stream,
295
+ status: "near-block",
296
+ retry: p.retry,
297
+ reason: `retry ${p.retry} \u2265 near-block threshold ${near_block}`
298
+ });
299
+ return;
300
+ }
301
+ if (p.leased_by && p.leased_until && p.leased_until.getTime() < stuck_cutoff) {
302
+ const minutes = Math.floor(
303
+ (Date.now() - p.leased_until.getTime()) / (60 * 1e3)
304
+ );
305
+ findings.push({
306
+ category: "reaction-health",
307
+ stream: p.stream,
308
+ status: "stuck-backoff",
309
+ retry: p.retry,
310
+ reason: `lease expired ${minutes}m ago without release`
311
+ });
312
+ }
313
+ },
314
+ drain: () => findings
315
+ };
316
+ };
317
+ var makeSnapshotDriftPass = (deps, options) => {
318
+ const drift_min = options.thresholds?.drift_min ?? DEFAULTS.drift_min;
319
+ const candidates = [];
320
+ const findings = [];
321
+ return {
322
+ category: "snapshot-drift",
323
+ onStat(stream, { head, count, names }) {
324
+ if (!restartIsSupported(deps, String(head.name))) return;
325
+ if (count < drift_min) return;
326
+ candidates.push({
327
+ stream,
328
+ total: count,
329
+ snaps: names["__snapshot__"] ?? 0
330
+ });
331
+ },
332
+ async finalize(deps2) {
333
+ for (const { stream, total, snaps } of candidates) {
334
+ let events_since_snap = total;
335
+ let snap_at;
336
+ if (snaps > 0) {
337
+ const collected = [];
338
+ await deps2.store().query(
339
+ (e) => {
340
+ collected.push({ id: e.id });
341
+ },
342
+ {
343
+ stream,
344
+ stream_exact: true,
345
+ names: ["__snapshot__"],
346
+ backward: true,
347
+ limit: 1,
348
+ with_snaps: true
349
+ }
350
+ );
351
+ snap_at = collected[0].id;
352
+ let after = 0;
353
+ await deps2.store().query(
354
+ () => {
355
+ after++;
356
+ },
357
+ { stream, stream_exact: true, after: snap_at }
358
+ );
359
+ events_since_snap = after;
360
+ }
361
+ if (events_since_snap < drift_min) continue;
362
+ findings.push({
363
+ category: "snapshot-drift",
364
+ stream,
365
+ events_since_snap,
366
+ snap_at
367
+ });
368
+ }
369
+ },
370
+ drain: () => findings
371
+ };
372
+ };
373
+ var makeRoutingHealthPass = (deps) => {
374
+ const findings = [];
375
+ const seenEventNames = /* @__PURE__ */ new Set();
376
+ return {
377
+ category: "routing-health",
378
+ onStream(p) {
379
+ if (!p.lane) return;
380
+ if (deps.declared_lanes.has(p.lane)) return;
381
+ findings.push({
382
+ category: "routing-health",
383
+ stream: p.stream,
384
+ reason: "unknown-lane",
385
+ lane: p.lane
386
+ });
387
+ },
388
+ onStat(_stream, { names }) {
389
+ for (const name of Object.keys(names)) {
390
+ seenEventNames.add(name);
391
+ }
392
+ },
393
+ finalize() {
394
+ for (const name of seenEventNames) {
395
+ if (name.startsWith("__")) continue;
396
+ if (deps.routed_events.has(name)) continue;
397
+ findings.push({
398
+ category: "routing-health",
399
+ stream: "*",
400
+ reason: "unrouted"
401
+ });
402
+ }
403
+ return Promise.resolve();
404
+ },
405
+ drain: () => findings
406
+ };
407
+ };
408
+ var makeCorrelationGapsPass = () => {
409
+ const seenIds = /* @__PURE__ */ new Set();
410
+ const checks = [];
411
+ return {
412
+ category: "correlation-gaps",
413
+ onEvent(e) {
414
+ seenIds.add(e.id);
415
+ const causation = e.meta?.causation;
416
+ const parentId = causation?.event?.id;
417
+ if (parentId !== void 0) {
418
+ checks.push({ stream: e.stream, id: e.id, parentId });
419
+ }
420
+ },
421
+ drain() {
422
+ const findings = [];
423
+ for (const { stream, id, parentId } of checks) {
424
+ if (!seenIds.has(parentId)) {
425
+ findings.push({
426
+ category: "correlation-gaps",
427
+ stream,
428
+ event_id: id,
429
+ reason: "orphan-parent"
430
+ });
431
+ }
432
+ }
433
+ return findings;
434
+ }
435
+ };
436
+ };
437
+ var makeClockAnomaliesPass = () => {
438
+ const findings = [];
439
+ const lastPerStream = /* @__PURE__ */ new Map();
440
+ return {
441
+ category: "clock-anomalies",
442
+ onEvent(e) {
443
+ const created = e.created.getTime();
444
+ if (created > Date.now()) {
445
+ findings.push({
446
+ category: "clock-anomalies",
447
+ stream: e.stream,
448
+ event_id: e.id,
449
+ reason: "future-created"
450
+ });
451
+ }
452
+ const prev = lastPerStream.get(e.stream);
453
+ if (prev !== void 0 && created < prev) {
454
+ findings.push({
455
+ category: "clock-anomalies",
456
+ stream: e.stream,
457
+ event_id: e.id,
458
+ reason: "out-of-order"
459
+ });
460
+ }
461
+ lastPerStream.set(e.stream, created);
462
+ },
463
+ drain: () => findings
464
+ };
465
+ };
466
+ function restartIsSupported(deps, headEventName) {
467
+ const state2 = deps.event_to_state.get(headEventName);
468
+ return state2?.snap !== void 0;
469
+ }
470
+ var PASS_FACTORIES = {
471
+ schema: makeSchemaPass,
472
+ "deprecated-load": makeDeprecatedLoadPass,
473
+ "close-candidate": makeCloseCandidatePass,
474
+ "restart-candidate": makeRestartCandidatePass,
475
+ "reaction-health": makeReactionHealthPass,
476
+ "snapshot-drift": makeSnapshotDriftPass,
477
+ "routing-health": makeRoutingHealthPass,
478
+ "correlation-gaps": makeCorrelationGapsPass,
479
+ "clock-anomalies": makeClockAnomaliesPass
480
+ };
481
+
63
482
  // src/internal/build-classify.ts
64
483
  var ALL_LANES = /* @__PURE__ */ Symbol("act-1103/all-lanes");
65
484
  function classifyRegistry(registry, states) {
@@ -1035,43 +1454,6 @@ var DrainController = class {
1035
1454
  }
1036
1455
  };
1037
1456
 
1038
- // src/internal/event-versions.ts
1039
- var VERSION_SUFFIX = /^(.+?)_v(\d+)$/;
1040
- function parse(name) {
1041
- const m = name.match(VERSION_SUFFIX);
1042
- if (m) {
1043
- const v = Number.parseInt(m[2], 10);
1044
- if (v >= 2) return { base: m[1], version: v };
1045
- }
1046
- return { base: name, version: 1 };
1047
- }
1048
- function deprecatedEventNames(names) {
1049
- const groups = /* @__PURE__ */ new Map();
1050
- for (const name of names) {
1051
- const { base, version } = parse(name);
1052
- const list = groups.get(base);
1053
- if (list) list.push({ version, name });
1054
- else groups.set(base, [{ version, name }]);
1055
- }
1056
- const deprecated = /* @__PURE__ */ new Set();
1057
- for (const list of groups.values()) {
1058
- if (list.length < 2) continue;
1059
- list.sort((a, b) => b.version - a.version);
1060
- for (let i = 1; i < list.length; i++) deprecated.add(list[i].name);
1061
- }
1062
- return deprecated;
1063
- }
1064
- function currentVersionOf(deprecatedName, allNames) {
1065
- const target = parse(deprecatedName);
1066
- let highest;
1067
- for (const name of allNames) {
1068
- const { base, version } = parse(name);
1069
- if (base !== target.base) continue;
1070
- if (!highest || version > highest.version) highest = { version, name };
1071
- }
1072
- return highest && highest.version > target.version ? highest.name : void 0;
1073
- }
1074
-
1075
1457
  // src/internal/merge.ts
1076
1458
  import { ZodObject } from "zod";
1077
1459
  function baseTypeName(zodType) {
@@ -1461,6 +1843,15 @@ var Act = class {
1461
1843
  if (cfg?.cycleMs !== void 0) controller.start(cfg.cycleMs);
1462
1844
  this._drain_controllers.set(name, controller);
1463
1845
  }
1846
+ this._audit_deps = {
1847
+ store,
1848
+ logger: this._logger,
1849
+ event_to_state: eventToState,
1850
+ states: this._states,
1851
+ known_events: new Set(eventToState.keys()),
1852
+ declared_lanes: new Set(this._drain_controllers.keys()),
1853
+ routed_events: new Set(eventToLanes.keys())
1854
+ };
1464
1855
  this._correlate = new CorrelateCycle(
1465
1856
  this.registry,
1466
1857
  staticTargets,
@@ -1552,6 +1943,14 @@ var Act = class {
1552
1943
  * reactions target.
1553
1944
  */
1554
1945
  _event_to_lanes;
1946
+ /**
1947
+ * Audit dependency bag (#723). Built once at construction; held as
1948
+ * an immutable snapshot of the registry state the audit module
1949
+ * needs. Lives in `internal/audit.ts` — this orchestrator never
1950
+ * carries audit logic, only the deps + a one-liner that hands them
1951
+ * over.
1952
+ */
1953
+ _audit_deps;
1555
1954
  /** Logger resolved at construction time (after user port configuration) */
1556
1955
  _logger = log();
1557
1956
  /** Wraps a public-method body so internal `store()`/`cache()` resolve to the
@@ -2168,6 +2567,50 @@ var Act = class {
2168
2567
  return positions;
2169
2568
  });
2170
2569
  }
2570
+ /**
2571
+ * Operator-driven store audit (#723).
2572
+ *
2573
+ * Walks the connected store and yields per-category findings —
2574
+ * each tagged with the remediation it suggests. Same operator-
2575
+ * driven category as `app.close()` / `app.reset()` /
2576
+ * `app.unblock()` / `app.blocked_streams()`: never auto-invoked by
2577
+ * the framework; the operator decides when to run it (CI gate,
2578
+ * scheduled job, ad-hoc forensics) and what to do with the
2579
+ * findings.
2580
+ *
2581
+ * Categories are independent — pass a subset to scope the work,
2582
+ * or omit to run everything:
2583
+ *
2584
+ * ```typescript
2585
+ * // Targeted: schema drift + deprecated-event load only
2586
+ * for await (const f of app.audit(["schema", "deprecated-load"], {
2587
+ * query: { created_after: lastScan },
2588
+ * thresholds: { deprecatedLoadShareMin: 0.10 },
2589
+ * })) {
2590
+ * await escalate(f);
2591
+ * }
2592
+ *
2593
+ * // Full audit, default thresholds
2594
+ * for await (const f of app.audit()) console.log(f);
2595
+ * ```
2596
+ *
2597
+ * Returns an `AsyncIterable` so callers can `break` early — the
2598
+ * underlying store paginations respect the iterator protocol and
2599
+ * stop cleanly. Each finding is emitted independently, so
2600
+ * pipelining into Slack / persistence / further analysis works
2601
+ * without buffering the full report in memory.
2602
+ *
2603
+ * Findings shape — see {@link AuditFinding}. The discriminated
2604
+ * union carries enough context for the operator to act on each
2605
+ * finding directly: stream id, event id, recommendation hints.
2606
+ *
2607
+ * @param categories - Subset of categories to run (default: all).
2608
+ * @param options - Query window + per-category thresholds.
2609
+ * @returns Async iterable of {@link AuditFinding}.
2610
+ */
2611
+ audit(categories, options) {
2612
+ return audit(this._audit_deps, categories, options);
2613
+ }
2171
2614
  /**
2172
2615
  * Bulk-update scheduling priority for streams matching `filter`.
2173
2616
  *