@rotorsoft/act 0.45.0 → 1.0.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.cjs CHANGED
@@ -1235,6 +1235,425 @@ process.once("unhandledRejection", async (arg) => {
1235
1235
  // src/act.ts
1236
1236
  var import_node_events = __toESM(require("events"), 1);
1237
1237
 
1238
+ // src/internal/event-versions.ts
1239
+ var VERSION_SUFFIX = /^(.+?)_v(\d+)$/;
1240
+ function parse(name) {
1241
+ const m = name.match(VERSION_SUFFIX);
1242
+ if (m) {
1243
+ const v = Number.parseInt(m[2], 10);
1244
+ if (v >= 2) return { base: m[1], version: v };
1245
+ }
1246
+ return { base: name, version: 1 };
1247
+ }
1248
+ function deprecatedEventNames(names) {
1249
+ const groups = /* @__PURE__ */ new Map();
1250
+ for (const name of names) {
1251
+ const { base, version } = parse(name);
1252
+ const list = groups.get(base);
1253
+ if (list) list.push({ version, name });
1254
+ else groups.set(base, [{ version, name }]);
1255
+ }
1256
+ const deprecated = /* @__PURE__ */ new Set();
1257
+ for (const list of groups.values()) {
1258
+ if (list.length < 2) continue;
1259
+ list.sort((a, b) => b.version - a.version);
1260
+ for (let i = 1; i < list.length; i++) deprecated.add(list[i].name);
1261
+ }
1262
+ return deprecated;
1263
+ }
1264
+ function currentVersionOf(deprecatedName, allNames) {
1265
+ const target = parse(deprecatedName);
1266
+ let highest;
1267
+ for (const name of allNames) {
1268
+ const { base, version } = parse(name);
1269
+ if (base !== target.base) continue;
1270
+ if (!highest || version > highest.version) highest = { version, name };
1271
+ }
1272
+ return highest && highest.version > target.version ? highest.name : void 0;
1273
+ }
1274
+
1275
+ // src/internal/audit.ts
1276
+ var DEFAULTS = {
1277
+ idle_days: 90,
1278
+ restart_min: 1e4,
1279
+ stuck_minutes: 30,
1280
+ deprecated_min: 0.1,
1281
+ drift_min: 500,
1282
+ near_block: 3
1283
+ };
1284
+ var ALL_CATEGORIES = [
1285
+ "schema",
1286
+ "close-candidate",
1287
+ "restart-candidate",
1288
+ "deprecated-load",
1289
+ "reaction-health",
1290
+ "snapshot-drift",
1291
+ "routing-health",
1292
+ "correlation-gaps",
1293
+ "clock-anomalies"
1294
+ ];
1295
+ async function* audit(deps, categories, options = {}) {
1296
+ const requested = new Set(categories ?? [...ALL_CATEGORIES]);
1297
+ const orderedCategories = ALL_CATEGORIES.filter((c) => requested.has(c));
1298
+ const passes = orderedCategories.map(
1299
+ (c) => PASS_FACTORIES[c](deps, options)
1300
+ );
1301
+ const needStats = passes.some((p) => p.onStat !== void 0);
1302
+ const needStreams = passes.some((p) => p.onStream !== void 0);
1303
+ const needEvents = passes.some((p) => p.onEvent !== void 0);
1304
+ if (needStats) {
1305
+ const stats = await deps.store().query_stats({}, { count: true, names: true });
1306
+ for (const [stream, s] of stats) {
1307
+ for (const p of passes) p.onStat?.(stream, s);
1308
+ }
1309
+ }
1310
+ if (needStreams) {
1311
+ await deps.store().query_streams((pos) => {
1312
+ for (const p of passes) p.onStream?.(pos);
1313
+ });
1314
+ }
1315
+ if (needEvents) {
1316
+ await deps.store().query((event) => {
1317
+ for (const p of passes) p.onEvent?.(event);
1318
+ }, options.query);
1319
+ }
1320
+ for (const p of passes) await p.finalize?.(deps);
1321
+ for (const p of passes) {
1322
+ for (const f of p.drain()) yield f;
1323
+ }
1324
+ }
1325
+ var makeSchemaPass = (deps) => {
1326
+ const findings = [];
1327
+ return {
1328
+ category: "schema",
1329
+ onEvent(event) {
1330
+ const name = String(event.name);
1331
+ const state2 = deps.event_to_state.get(name);
1332
+ if (!state2) {
1333
+ if (name.startsWith("__")) return;
1334
+ findings.push({
1335
+ category: "schema",
1336
+ stream: event.stream,
1337
+ event_id: event.id,
1338
+ name,
1339
+ reason: "unknown_event_name"
1340
+ });
1341
+ return;
1342
+ }
1343
+ const schema = state2.events[name];
1344
+ const parsed = schema.safeParse(event.data);
1345
+ if (!parsed.success) {
1346
+ findings.push({
1347
+ category: "schema",
1348
+ stream: event.stream,
1349
+ event_id: event.id,
1350
+ name,
1351
+ reason: "schema_validation_failed",
1352
+ zod_error: parsed.error
1353
+ });
1354
+ }
1355
+ },
1356
+ drain: () => findings
1357
+ };
1358
+ };
1359
+ var makeDeprecatedLoadPass = (deps, options) => {
1360
+ const share_min = options.thresholds?.deprecated_min ?? DEFAULTS.deprecated_min;
1361
+ const totals = /* @__PURE__ */ new Map();
1362
+ const perStream = /* @__PURE__ */ new Map();
1363
+ return {
1364
+ category: "deprecated-load",
1365
+ onStat(stream, { names }) {
1366
+ for (const [name, count] of Object.entries(names)) {
1367
+ totals.set(name, (totals.get(name) ?? 0) + count);
1368
+ let m = perStream.get(name);
1369
+ if (!m) {
1370
+ m = /* @__PURE__ */ new Map();
1371
+ perStream.set(name, m);
1372
+ }
1373
+ m.set(stream, count);
1374
+ }
1375
+ },
1376
+ drain() {
1377
+ const findings = [];
1378
+ const grand = [...totals.values()].reduce((s, n) => s + n, 0);
1379
+ if (grand === 0) return findings;
1380
+ const deprecated = deprecatedEventNames(deps.known_events);
1381
+ const sorted = [...deprecated].map((name) => ({ name, count: totals.get(name) ?? 0 })).sort((a, b) => b.count - a.count);
1382
+ for (const { name, count } of sorted) {
1383
+ if (count === 0) continue;
1384
+ if (count / grand < share_min) continue;
1385
+ const currentVersion = currentVersionOf(name, deps.known_events);
1386
+ const topStreams = [...perStream.get(name).entries()].map(([stream, c]) => ({ stream, count: c })).sort((a, b) => b.count - a.count).slice(0, 10);
1387
+ findings.push({
1388
+ category: "deprecated-load",
1389
+ name,
1390
+ current_version: currentVersion,
1391
+ total: count,
1392
+ top_streams: topStreams
1393
+ });
1394
+ }
1395
+ return findings;
1396
+ }
1397
+ };
1398
+ };
1399
+ var makeCloseCandidatePass = (deps, options) => {
1400
+ const idle_days = options.thresholds?.idle_days ?? DEFAULTS.idle_days;
1401
+ const terminal_events = new Set(options.thresholds?.terminal_events ?? []);
1402
+ const idle_cutoff = Date.now() - idle_days * 24 * 60 * 60 * 1e3;
1403
+ const findings = [];
1404
+ return {
1405
+ category: "close-candidate",
1406
+ onStat(stream, { head }) {
1407
+ const head_name = String(head.name);
1408
+ if (head_name.startsWith("__")) return;
1409
+ const head_time = head.created.getTime();
1410
+ const is_idle = head_time < idle_cutoff;
1411
+ const is_terminal = terminal_events.has(head_name);
1412
+ if (!is_idle && !is_terminal) return;
1413
+ findings.push({
1414
+ category: "close-candidate",
1415
+ stream,
1416
+ last_event_at: head.created.toISOString(),
1417
+ reason: is_terminal ? "terminal" : "idle",
1418
+ idle_days: is_idle ? Math.floor((Date.now() - head_time) / (24 * 60 * 60 * 1e3)) : void 0,
1419
+ restart_supported: restartIsSupported(deps, head_name)
1420
+ });
1421
+ },
1422
+ drain: () => findings
1423
+ };
1424
+ };
1425
+ var makeRestartCandidatePass = (deps, options) => {
1426
+ const threshold = options.thresholds?.restart_min ?? DEFAULTS.restart_min;
1427
+ const findings = [];
1428
+ return {
1429
+ category: "restart-candidate",
1430
+ onStat(stream, { head, count, names }) {
1431
+ if (count < threshold) return;
1432
+ const head_name = String(head.name);
1433
+ if (head_name.startsWith("__")) return;
1434
+ if (!restartIsSupported(deps, head_name)) return;
1435
+ findings.push({
1436
+ category: "restart-candidate",
1437
+ stream,
1438
+ count,
1439
+ // names map is sparse — `__snapshot__` key absent when the
1440
+ // stream has never been snapshotted (a common case for the
1441
+ // restart-candidate signal).
1442
+ snaps: names["__snapshot__"] ?? 0
1443
+ });
1444
+ },
1445
+ drain: () => findings
1446
+ };
1447
+ };
1448
+ var makeReactionHealthPass = (_deps, options) => {
1449
+ const near_block = options.thresholds?.near_block ?? DEFAULTS.near_block;
1450
+ const stuck_minutes = options.thresholds?.stuck_minutes ?? DEFAULTS.stuck_minutes;
1451
+ const stuck_cutoff = Date.now() - stuck_minutes * 60 * 1e3;
1452
+ const findings = [];
1453
+ return {
1454
+ category: "reaction-health",
1455
+ onStream(p) {
1456
+ if (p.blocked) {
1457
+ findings.push({
1458
+ category: "reaction-health",
1459
+ stream: p.stream,
1460
+ status: "blocked",
1461
+ retry: p.retry,
1462
+ reason: p.error || "blocked without recorded error"
1463
+ });
1464
+ return;
1465
+ }
1466
+ if (p.retry >= near_block) {
1467
+ findings.push({
1468
+ category: "reaction-health",
1469
+ stream: p.stream,
1470
+ status: "near-block",
1471
+ retry: p.retry,
1472
+ reason: `retry ${p.retry} \u2265 near-block threshold ${near_block}`
1473
+ });
1474
+ return;
1475
+ }
1476
+ if (p.leased_by && p.leased_until && p.leased_until.getTime() < stuck_cutoff) {
1477
+ const minutes = Math.floor(
1478
+ (Date.now() - p.leased_until.getTime()) / (60 * 1e3)
1479
+ );
1480
+ findings.push({
1481
+ category: "reaction-health",
1482
+ stream: p.stream,
1483
+ status: "stuck-backoff",
1484
+ retry: p.retry,
1485
+ reason: `lease expired ${minutes}m ago without release`
1486
+ });
1487
+ }
1488
+ },
1489
+ drain: () => findings
1490
+ };
1491
+ };
1492
+ var makeSnapshotDriftPass = (deps, options) => {
1493
+ const drift_min = options.thresholds?.drift_min ?? DEFAULTS.drift_min;
1494
+ const candidates = [];
1495
+ const findings = [];
1496
+ return {
1497
+ category: "snapshot-drift",
1498
+ onStat(stream, { head, count, names }) {
1499
+ if (!restartIsSupported(deps, String(head.name))) return;
1500
+ if (count < drift_min) return;
1501
+ candidates.push({
1502
+ stream,
1503
+ total: count,
1504
+ snaps: names["__snapshot__"] ?? 0
1505
+ });
1506
+ },
1507
+ async finalize(deps2) {
1508
+ for (const { stream, total, snaps } of candidates) {
1509
+ let events_since_snap = total;
1510
+ let snap_at;
1511
+ if (snaps > 0) {
1512
+ const collected = [];
1513
+ await deps2.store().query(
1514
+ (e) => {
1515
+ collected.push({ id: e.id });
1516
+ },
1517
+ {
1518
+ stream,
1519
+ stream_exact: true,
1520
+ names: ["__snapshot__"],
1521
+ backward: true,
1522
+ limit: 1,
1523
+ with_snaps: true
1524
+ }
1525
+ );
1526
+ snap_at = collected[0].id;
1527
+ let after = 0;
1528
+ await deps2.store().query(
1529
+ () => {
1530
+ after++;
1531
+ },
1532
+ { stream, stream_exact: true, after: snap_at }
1533
+ );
1534
+ events_since_snap = after;
1535
+ }
1536
+ if (events_since_snap < drift_min) continue;
1537
+ findings.push({
1538
+ category: "snapshot-drift",
1539
+ stream,
1540
+ events_since_snap,
1541
+ snap_at
1542
+ });
1543
+ }
1544
+ },
1545
+ drain: () => findings
1546
+ };
1547
+ };
1548
+ var makeRoutingHealthPass = (deps) => {
1549
+ const findings = [];
1550
+ const seenEventNames = /* @__PURE__ */ new Set();
1551
+ return {
1552
+ category: "routing-health",
1553
+ onStream(p) {
1554
+ if (!p.lane) return;
1555
+ if (deps.declared_lanes.has(p.lane)) return;
1556
+ findings.push({
1557
+ category: "routing-health",
1558
+ stream: p.stream,
1559
+ reason: "unknown-lane",
1560
+ lane: p.lane
1561
+ });
1562
+ },
1563
+ onStat(_stream, { names }) {
1564
+ for (const name of Object.keys(names)) {
1565
+ seenEventNames.add(name);
1566
+ }
1567
+ },
1568
+ finalize() {
1569
+ for (const name of seenEventNames) {
1570
+ if (name.startsWith("__")) continue;
1571
+ if (deps.routed_events.has(name)) continue;
1572
+ findings.push({
1573
+ category: "routing-health",
1574
+ stream: "*",
1575
+ reason: "unrouted"
1576
+ });
1577
+ }
1578
+ return Promise.resolve();
1579
+ },
1580
+ drain: () => findings
1581
+ };
1582
+ };
1583
+ var makeCorrelationGapsPass = () => {
1584
+ const seenIds = /* @__PURE__ */ new Set();
1585
+ const checks = [];
1586
+ return {
1587
+ category: "correlation-gaps",
1588
+ onEvent(e) {
1589
+ seenIds.add(e.id);
1590
+ const causation = e.meta?.causation;
1591
+ const parentId = causation?.event?.id;
1592
+ if (parentId !== void 0) {
1593
+ checks.push({ stream: e.stream, id: e.id, parentId });
1594
+ }
1595
+ },
1596
+ drain() {
1597
+ const findings = [];
1598
+ for (const { stream, id, parentId } of checks) {
1599
+ if (!seenIds.has(parentId)) {
1600
+ findings.push({
1601
+ category: "correlation-gaps",
1602
+ stream,
1603
+ event_id: id,
1604
+ reason: "orphan-parent"
1605
+ });
1606
+ }
1607
+ }
1608
+ return findings;
1609
+ }
1610
+ };
1611
+ };
1612
+ var makeClockAnomaliesPass = () => {
1613
+ const findings = [];
1614
+ const lastPerStream = /* @__PURE__ */ new Map();
1615
+ return {
1616
+ category: "clock-anomalies",
1617
+ onEvent(e) {
1618
+ const created = e.created.getTime();
1619
+ if (created > Date.now()) {
1620
+ findings.push({
1621
+ category: "clock-anomalies",
1622
+ stream: e.stream,
1623
+ event_id: e.id,
1624
+ reason: "future-created"
1625
+ });
1626
+ }
1627
+ const prev = lastPerStream.get(e.stream);
1628
+ if (prev !== void 0 && created < prev) {
1629
+ findings.push({
1630
+ category: "clock-anomalies",
1631
+ stream: e.stream,
1632
+ event_id: e.id,
1633
+ reason: "out-of-order"
1634
+ });
1635
+ }
1636
+ lastPerStream.set(e.stream, created);
1637
+ },
1638
+ drain: () => findings
1639
+ };
1640
+ };
1641
+ function restartIsSupported(deps, headEventName) {
1642
+ const state2 = deps.event_to_state.get(headEventName);
1643
+ return state2?.snap !== void 0;
1644
+ }
1645
+ var PASS_FACTORIES = {
1646
+ schema: makeSchemaPass,
1647
+ "deprecated-load": makeDeprecatedLoadPass,
1648
+ "close-candidate": makeCloseCandidatePass,
1649
+ "restart-candidate": makeRestartCandidatePass,
1650
+ "reaction-health": makeReactionHealthPass,
1651
+ "snapshot-drift": makeSnapshotDriftPass,
1652
+ "routing-health": makeRoutingHealthPass,
1653
+ "correlation-gaps": makeCorrelationGapsPass,
1654
+ "clock-anomalies": makeClockAnomaliesPass
1655
+ };
1656
+
1238
1657
  // src/internal/build-classify.ts
1239
1658
  var ALL_LANES = /* @__PURE__ */ Symbol("act-1103/all-lanes");
1240
1659
  function classifyRegistry(registry, states) {
@@ -2210,43 +2629,6 @@ var DrainController = class {
2210
2629
  }
2211
2630
  };
2212
2631
 
2213
- // src/internal/event-versions.ts
2214
- var VERSION_SUFFIX = /^(.+?)_v(\d+)$/;
2215
- function parse(name) {
2216
- const m = name.match(VERSION_SUFFIX);
2217
- if (m) {
2218
- const v = Number.parseInt(m[2], 10);
2219
- if (v >= 2) return { base: m[1], version: v };
2220
- }
2221
- return { base: name, version: 1 };
2222
- }
2223
- function deprecatedEventNames(names) {
2224
- const groups = /* @__PURE__ */ new Map();
2225
- for (const name of names) {
2226
- const { base, version } = parse(name);
2227
- const list = groups.get(base);
2228
- if (list) list.push({ version, name });
2229
- else groups.set(base, [{ version, name }]);
2230
- }
2231
- const deprecated = /* @__PURE__ */ new Set();
2232
- for (const list of groups.values()) {
2233
- if (list.length < 2) continue;
2234
- list.sort((a, b) => b.version - a.version);
2235
- for (let i = 1; i < list.length; i++) deprecated.add(list[i].name);
2236
- }
2237
- return deprecated;
2238
- }
2239
- function currentVersionOf(deprecatedName, allNames) {
2240
- const target = parse(deprecatedName);
2241
- let highest;
2242
- for (const name of allNames) {
2243
- const { base, version } = parse(name);
2244
- if (base !== target.base) continue;
2245
- if (!highest || version > highest.version) highest = { version, name };
2246
- }
2247
- return highest && highest.version > target.version ? highest.name : void 0;
2248
- }
2249
-
2250
2632
  // src/internal/merge.ts
2251
2633
  var import_zod4 = require("zod");
2252
2634
  function baseTypeName(zodType) {
@@ -2636,6 +3018,15 @@ var Act = class {
2636
3018
  if (cfg?.cycleMs !== void 0) controller.start(cfg.cycleMs);
2637
3019
  this._drain_controllers.set(name, controller);
2638
3020
  }
3021
+ this._audit_deps = {
3022
+ store: store2,
3023
+ logger: this._logger,
3024
+ event_to_state: eventToState,
3025
+ states: this._states,
3026
+ known_events: new Set(eventToState.keys()),
3027
+ declared_lanes: new Set(this._drain_controllers.keys()),
3028
+ routed_events: new Set(eventToLanes.keys())
3029
+ };
2639
3030
  this._correlate = new CorrelateCycle(
2640
3031
  this.registry,
2641
3032
  staticTargets,
@@ -2727,6 +3118,14 @@ var Act = class {
2727
3118
  * reactions target.
2728
3119
  */
2729
3120
  _event_to_lanes;
3121
+ /**
3122
+ * Audit dependency bag (#723). Built once at construction; held as
3123
+ * an immutable snapshot of the registry state the audit module
3124
+ * needs. Lives in `internal/audit.ts` — this orchestrator never
3125
+ * carries audit logic, only the deps + a one-liner that hands them
3126
+ * over.
3127
+ */
3128
+ _audit_deps;
2730
3129
  /** Logger resolved at construction time (after user port configuration) */
2731
3130
  _logger = log();
2732
3131
  /** Wraps a public-method body so internal `store()`/`cache()` resolve to the
@@ -3343,6 +3742,50 @@ var Act = class {
3343
3742
  return positions;
3344
3743
  });
3345
3744
  }
3745
+ /**
3746
+ * Operator-driven store audit (#723).
3747
+ *
3748
+ * Walks the connected store and yields per-category findings —
3749
+ * each tagged with the remediation it suggests. Same operator-
3750
+ * driven category as `app.close()` / `app.reset()` /
3751
+ * `app.unblock()` / `app.blocked_streams()`: never auto-invoked by
3752
+ * the framework; the operator decides when to run it (CI gate,
3753
+ * scheduled job, ad-hoc forensics) and what to do with the
3754
+ * findings.
3755
+ *
3756
+ * Categories are independent — pass a subset to scope the work,
3757
+ * or omit to run everything:
3758
+ *
3759
+ * ```typescript
3760
+ * // Targeted: schema drift + deprecated-event load only
3761
+ * for await (const f of app.audit(["schema", "deprecated-load"], {
3762
+ * query: { created_after: lastScan },
3763
+ * thresholds: { deprecatedLoadShareMin: 0.10 },
3764
+ * })) {
3765
+ * await escalate(f);
3766
+ * }
3767
+ *
3768
+ * // Full audit, default thresholds
3769
+ * for await (const f of app.audit()) console.log(f);
3770
+ * ```
3771
+ *
3772
+ * Returns an `AsyncIterable` so callers can `break` early — the
3773
+ * underlying store paginations respect the iterator protocol and
3774
+ * stop cleanly. Each finding is emitted independently, so
3775
+ * pipelining into Slack / persistence / further analysis works
3776
+ * without buffering the full report in memory.
3777
+ *
3778
+ * Findings shape — see {@link AuditFinding}. The discriminated
3779
+ * union carries enough context for the operator to act on each
3780
+ * finding directly: stream id, event id, recommendation hints.
3781
+ *
3782
+ * @param categories - Subset of categories to run (default: all).
3783
+ * @param options - Query window + per-category thresholds.
3784
+ * @returns Async iterable of {@link AuditFinding}.
3785
+ */
3786
+ audit(categories, options) {
3787
+ return audit(this._audit_deps, categories, options);
3788
+ }
3346
3789
  /**
3347
3790
  * Bulk-update scheduling priority for streams matching `filter`.
3348
3791
  *