@anteros/core 0.0.41 → 0.0.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/database/audit.ts +9 -59
  2. package/database/collection.ts +3 -1
  3. package/database/deleteLog.ts +67 -3
  4. package/database/file.ts +4 -3
  5. package/database/mongodbadapter.ts +103 -42
  6. package/database/replication.ts +110 -32
  7. package/database/ttl.ts +68 -0
  8. package/database/workflow.ts +466 -240
  9. package/index.ts +6 -0
  10. package/lib/agent.ts +1327 -0
  11. package/lib/agentMemory.ts +586 -0
  12. package/lib/agents.ts +157 -0
  13. package/lib/ai.ts +0 -0
  14. package/lib/api.ts +246 -0
  15. package/lib/attachment.ts +351 -0
  16. package/lib/auditResult.ts +13 -0
  17. package/lib/define.ts +43 -1
  18. package/lib/endpoints.ts +80 -0
  19. package/lib/files.ts +2 -1
  20. package/lib/jsonSchema.ts +183 -0
  21. package/lib/lifecycle.ts +7 -1
  22. package/lib/load.ts +17 -0
  23. package/lib/mcp.ts +3 -2
  24. package/lib/messages.ts +299 -0
  25. package/lib/providers.ts +867 -0
  26. package/lib/routes.ts +14 -1
  27. package/lib/scriptWorker.ts +4 -1
  28. package/lib/scripts.ts +6 -1
  29. package/lib/sockets.ts +3 -1
  30. package/lib/workflow.ts +209 -37
  31. package/lib/zip.ts +109 -0
  32. package/package.json +1 -1
  33. package/server/access.ts +96 -0
  34. package/server/agents.ts +451 -0
  35. package/server/api.ts +14 -72
  36. package/server/boot.ts +11 -0
  37. package/server/hono.ts +8 -3
  38. package/server/mcp.ts +17 -63
  39. package/server/routes.ts +19 -24
  40. package/tests/agent-memory.test.ts +617 -0
  41. package/tests/agent-working-memory.test.ts +402 -0
  42. package/tests/agent.test.ts +1239 -0
  43. package/tests/agents-api.test.ts +593 -0
  44. package/tests/api.test.ts +51 -0
  45. package/tests/attachment.test.ts +241 -0
  46. package/tests/audit-operations.test.ts +1 -1
  47. package/tests/audit-redact.test.ts +29 -3
  48. package/tests/audit-run-action.test.ts +1 -1
  49. package/tests/endpoints.test.ts +80 -0
  50. package/tests/fixtures/agent-tenant/agents/support.agent.ts +9 -0
  51. package/tests/fixtures/agent-tenant/agents/weather.agent.ts +21 -0
  52. package/tests/fixtures/agents-api/agents/assistant.agent.ts +46 -0
  53. package/tests/fixtures/agents-api/agents/plain.agent.ts +23 -0
  54. package/tests/fixtures/agents-api/agents/private.agent.ts +15 -0
  55. package/tests/fixtures/agents-api/collections/notes.model.ts +26 -0
  56. package/tests/fixtures/agents-api/routes/probe.route.ts +21 -0
  57. package/tests/fixtures/agents-api/services/probe.service.ts +19 -0
  58. package/tests/fixtures/attachment/data.csv +2 -0
  59. package/tests/fixtures/fake-provider.ts +128 -0
  60. package/tests/fixtures/replication-meta/agents/chat.agent.ts +16 -0
  61. package/tests/fixtures/replication-meta/collections/counters.model.ts +13 -0
  62. package/tests/fixtures/replication-meta/collections/orders.model.ts +9 -0
  63. package/tests/fixtures/replication-meta/workflows/run.workflow.ts +8 -0
  64. package/tests/fixtures/vars-tenant/vars/private.var.ts +4 -1
  65. package/tests/fixtures/workflow-engine/workflows/failing.workflow.ts +34 -0
  66. package/tests/fixtures/workflow-engine/workflows/flaky.workflow.ts +23 -0
  67. package/tests/fixtures/workflow-engine/workflows/hang.workflow.ts +17 -0
  68. package/tests/fixtures/workflow-engine/workflows/simple.workflow.ts +19 -0
  69. package/tests/fixtures/workflow-engine/workflows/slow.workflow.ts +23 -0
  70. package/tests/fixtures/workflow-engine/workflows/versioned.workflow.ts +19 -0
  71. package/tests/fixtures/workflow-tenant/workflows/broken.workflow.ts +3 -0
  72. package/tests/fixtures/workflow-tenant/workflows/disabled.workflow.ts +8 -0
  73. package/tests/fixtures/workflow-tenant/workflows/invoice.workflow.ts +21 -0
  74. package/tests/fixtures/workflow-tenant/workflows/no-id.workflow.ts +7 -0
  75. package/tests/fixtures/workflow-tenant/workflows/order-duplicate.workflow.ts +13 -0
  76. package/tests/fixtures/workflow-tenant/workflows/order.workflow.ts +17 -0
  77. package/tests/hono.test.ts +6 -3
  78. package/tests/locks.test.ts +120 -0
  79. package/tests/mcp.test.ts +11 -11
  80. package/tests/messages.test.ts +258 -0
  81. package/tests/replication-control.test.ts +2 -2
  82. package/tests/replication-meta.test.ts +243 -0
  83. package/tests/upload.test.ts +44 -12
  84. package/tests/vars-api.test.ts +1 -1
  85. package/tests/vars-replication.test.ts +3 -2
  86. package/tests/workflow-engine.test.ts +340 -0
  87. package/tests/workflow-retention.test.ts +103 -0
  88. package/tests/workflow.test.ts +186 -0
  89. package/types/agent.d.ts +837 -0
  90. package/types/config.d.ts +16 -0
  91. package/types/file.d.ts +7 -7
  92. package/types/hook.d.ts +6 -0
  93. package/types/lifecycle.d.ts +6 -0
  94. package/types/mcp.d.ts +11 -1
  95. package/types/replication.d.ts +29 -0
  96. package/types/route.d.ts +7 -1
  97. package/types/scripts.d.ts +6 -0
  98. package/types/service.d.ts +7 -1
  99. package/types/tenant.d.ts +13 -0
  100. package/types/vars.d.ts +2 -2
  101. package/types/websocket.d.ts +6 -0
  102. package/types/workflow.d.ts +50 -3
@@ -4,6 +4,7 @@ import { getTenant } from "./tenant";
4
4
  import { useRest } from "./rest";
5
5
  import { logger } from "../utils/logger";
6
6
  import * as func from "../utils/func";
7
+ import * as os from "node:os";
7
8
  import type { Collection } from "../types/collection";
8
9
  import type { Tenant } from "../types/tenant";
9
10
  import type { HookContext } from "../types/hook";
@@ -11,6 +12,7 @@ import type {
11
12
  ReplicationConfig,
12
13
  ReplicationDestination,
13
14
  ReplicationInitialSync,
15
+ ReplicationMetaName,
14
16
  ReplicationResetOptions,
15
17
  ReplicationResetResult,
16
18
  ReplicationRunResult,
@@ -20,6 +22,10 @@ import type {
20
22
  ReplicationState,
21
23
  ReplicationStats,
22
24
  } from "../types/replication";
25
+ import { AUDIT_TTL_INDEX, resolveAuditRetention } from "./audit";
26
+ import { WORKFLOW_RUNS_TTL_INDEX, resolveWorkflowsRetention } from "../lib/workflow";
27
+ import { agentMemoryCollections } from "../lib/agents";
28
+ import { syncTtlRetention } from "./ttl";
23
29
 
24
30
  const META_COLLECTION = "_replication_";
25
31
  const DEFAULT_KEY = "updatedAt";
@@ -29,6 +35,76 @@ const BULK_CHUNK = 1000;
29
35
  /** Distributed-lock TTL used to guard a run across cluster workers. */
30
36
  const LOCK_TTL = 5 * 60_000;
31
37
 
38
+ /**
39
+ * Framework collections replication covers **by default**, with the date key each
40
+ * one is read with (the engine's cursor). `'vars'` is handled apart, per namespace,
41
+ * and `'memory'` covers the agent memory collections — whose names the tenant
42
+ * chooses, so they come from the agent registry instead (see below).
43
+ */
44
+ const META_COLLECTIONS: Record<Exclude<ReplicationMetaName, 'vars' | 'memory'>, { slug: string; key: string }> = {
45
+ audit: { slug: "_audit_", key: "ts" },
46
+ workflows: { slug: "_workflows_", key: "updatedAt" },
47
+ locks: { slug: "_locks_", key: "expiresAt" },
48
+ replication: { slug: "_replication_", key: "updatedAt" },
49
+ };
50
+
51
+ /**
52
+ * Everything replicated for a tenant: the collections that opted in, plus the
53
+ * framework's meta collections — which are replicated **by default** and left out
54
+ * with `replication.exclude: ['audit', 'locks', …]`.
55
+ */
56
+ function replicatedCollectionsFor(tenantId: string, config: ReplicationConfig): Collection[] {
57
+ const excluded = new Set<string>(config.exclude ?? []);
58
+
59
+ const collections: Collection[] = [
60
+ ...(cfg.collections ?? []).filter((c) => c._tenant_ === tenantId && c.replication?.enabled),
61
+ ...(cfg.fileCollections ?? [])
62
+ .filter((c) => c._tenant_ === tenantId && c.replication?.enabled)
63
+ .map((c) => ({ slug: c.slug, _tenant_: tenantId, replication: c.replication }) as any),
64
+ ];
65
+
66
+ for (const [name, meta] of Object.entries(META_COLLECTIONS)) {
67
+ if (excluded.has(name)) continue;
68
+ // Each meta collection knows its own date key (`_audit_` is indexed on `ts`),
69
+ // so a tenant-level `key` never breaks it
70
+ collections.push({
71
+ slug: meta.slug,
72
+ _tenant_: tenantId,
73
+ replication: { enabled: true, key: meta.key },
74
+ } as any);
75
+ }
76
+
77
+ if (!excluded.has('vars')) {
78
+ // Every namespace, minus the ones explicitly opting out — a per-document
79
+ // filter: `replication.exclude: ['vars']` is the blunt way out.
80
+ const optedOut = (cfg.vars ?? [])
81
+ .filter((v) => v._tenant_ === tenantId && v.replication?.enabled === false)
82
+ .map((v) => v.namespace);
83
+ collections.push({
84
+ slug: "_vars_",
85
+ _tenant_: tenantId,
86
+ replication: { enabled: true },
87
+ _baseFilter_: { ns: { $nin: optedOut } },
88
+ } as any);
89
+ }
90
+
91
+ // Agent memory collections — the tenant names them, the agent loader publishes
92
+ // them (`cfg.agentMemories`). `exclude: ['memory']` covers all of them.
93
+ if (!excluded.has('memory')) {
94
+ for (const slug of agentMemoryCollections(tenantId)) {
95
+ // A memory collection may also be declared as a collection: one entry only
96
+ if (collections.some((collection) => collection.slug === slug)) continue;
97
+ collections.push({
98
+ slug,
99
+ _tenant_: tenantId,
100
+ replication: { enabled: true, key: "updatedAt" },
101
+ } as any);
102
+ }
103
+ }
104
+
105
+ return collections;
106
+ }
107
+
32
108
  type Runtime = {
33
109
  tenantId: string;
34
110
  destination: ReplicationDestination;
@@ -221,6 +297,11 @@ async function saveState(
221
297
  status,
222
298
  error,
223
299
  stats: { ...stats },
300
+ // Which process owns this run — `reusePort`/forked deployments can
301
+ // see at a glance which worker is replicating (the lock holds the
302
+ // same information)
303
+ pid: process.pid,
304
+ hostname: os.hostname(),
224
305
  updatedAt: now,
225
306
  },
226
307
  $setOnInsert: { createdAt: now },
@@ -311,7 +392,15 @@ async function flushDeletes(
311
392
 
312
393
  const ops = markers.map((m: any) => ({
313
394
  deleteOne: {
314
- filter: { _id: toId(m.docId), [key]: { $lte: m.deletedAt } },
395
+ filter: {
396
+ _id: toId(m.docId),
397
+ // The key is a Date on most collections, but a number on `_locks_`:
398
+ // accept either, so a numeric key never silently skips the delete
399
+ $or: [
400
+ { [key]: { $lte: m.deletedAt } },
401
+ { [key]: { $lte: m.deletedAt?.getTime?.() ?? m.deletedAt } },
402
+ ],
403
+ },
315
404
  },
316
405
  }));
317
406
  const res = await withRetry(() => runtime.db.collection(collection.slug).bulkWrite(ops as any, { ordered: false }));
@@ -388,6 +477,23 @@ async function replicateCollection(
388
477
  await ensureIndex(tenant.database.db!, collection.slug, key, "source");
389
478
  await ensureIndex(runtime.db, collection.slug, key, `destination:${runtime.destination.id}`);
390
479
 
480
+ // Meta collections have no hooks, so their deletions never reach the
481
+ // destination (an audit TTL expiry is invisible to a date cursor): mirror the
482
+ // source's retention on the destination, otherwise the copy grows forever.
483
+ if (collection.slug === '_audit_' || collection.slug === '_workflows_') {
484
+ const isAudit = collection.slug === '_audit_';
485
+ const retention = isAudit ? resolveAuditRetention(tenant) : resolveWorkflowsRetention(tenant);
486
+ if (retention !== undefined) {
487
+ await syncTtlRetention(runtime.db, {
488
+ collection: collection.slug,
489
+ index: isAudit ? AUDIT_TTL_INDEX : WORKFLOW_RUNS_TTL_INDEX,
490
+ field: isAudit ? 'ts' : 'completedAt',
491
+ retention,
492
+ label: `${collection.slug} (destination)`,
493
+ });
494
+ }
495
+ }
496
+
391
497
  // Resolve the initial cursor on the first run — `initialSync` lets a
392
498
  // pre-loaded destination skip the full backfill (`full` = default).
393
499
  if (!state) {
@@ -452,29 +558,7 @@ async function replicateTenant(
452
558
  const mainDb = tenant?.database?.db;
453
559
  if (!tenant || !config || !mainDb) return [];
454
560
 
455
- const collections = (cfg.collections ?? []).filter((c) => c._tenant_ === tenantId && c.replication?.enabled);
456
-
457
- // File collections opt in the same way (`define.FileCollection({ replication })`).
458
- collections.push(
459
- ...(cfg.fileCollections ?? [])
460
- .filter((c) => c._tenant_ === tenantId && c.replication?.enabled)
461
- .map((c) => ({ slug: c.slug, _tenant_: tenantId, replication: c.replication }) as any),
462
- );
463
-
464
- // `define.Vars({ replication: { enabled: true } })` → replicate the opted-in
465
- // namespaces of `_vars_`. A per-document filter keeps the namespaces that
466
- // did NOT opt in (e.g. secrets) out of the destination.
467
- const varsNamespaces = (cfg.vars ?? [])
468
- .filter((v) => v._tenant_ === tenantId && v.replication?.enabled)
469
- .map((v) => v.namespace);
470
- if (varsNamespaces.length) {
471
- collections.push({
472
- slug: "_vars_",
473
- _tenant_: tenantId,
474
- replication: { enabled: true },
475
- _baseFilter_: { ns: { $in: varsNamespaces } },
476
- } as any);
477
- }
561
+ const collections = replicatedCollectionsFor(tenantId, config);
478
562
  const results: ReplicationRunResult[] = [];
479
563
  const meta = mainDb.collection(META_COLLECTION);
480
564
  const rest = new useRest({ tenant_id: tenantId });
@@ -606,14 +690,8 @@ async function seedReplication(
606
690
  if (!tenant || !config || !mainDb) return { tenant: tenantId, seeded: 0 };
607
691
 
608
692
  const meta: any = mainDb.collection(META_COLLECTION);
609
- const collections = (cfg.collections ?? []).filter(
610
- (c) => c._tenant_ === tenantId && c.replication?.enabled && (!opts?.collection || c.slug === opts.collection),
611
- );
612
- collections.push(
613
- ...(cfg.fileCollections ?? [])
614
- .filter((c) => c._tenant_ === tenantId && c.replication?.enabled && (!opts?.collection || c.slug === opts.collection))
615
- .map((c) => ({ slug: c.slug, _tenant_: tenantId, replication: c.replication }) as any),
616
- );
693
+ const collections = replicatedCollectionsFor(tenantId, config)
694
+ .filter((c) => !opts?.collection || c.slug === opts.collection);
617
695
  const destinations = (config.destinations ?? []).filter(
618
696
  (d) => d.enabled !== false && d.id && d.uri && (!opts?.destination || d.id === opts.destination),
619
697
  );
@@ -0,0 +1,68 @@
1
+ import type { Db } from "mongodb";
2
+ import { parseDuration } from "../utils/func";
3
+
4
+ export type RetentionResult = 'applied' | 'dropped' | 'unchanged' | 'skipped'
5
+
6
+ /**
7
+ * Declarative TTL retention for a collection:
8
+ *
9
+ * - a duration string (`'90d'`, `'24h'`) → ensure the TTL index with that expiry
10
+ * - `false` → explicitly disabled: drop the TTL index
11
+ * - omitted → untouched
12
+ *
13
+ * MongoDB cannot change `expireAfterSeconds` in place through `createIndex`:
14
+ * `collMod` does it instantly, dropping/recreating is the fallback. Never fatal —
15
+ * a failure is logged and `'skipped'` is returned.
16
+ */
17
+ async function syncTtlRetention(
18
+ db: Db,
19
+ opts: { collection: string; index: string; field: string; retention?: string | false; label?: string },
20
+ ): Promise<RetentionResult> {
21
+ const { collection, index, field, retention } = opts;
22
+ const label = opts.label ?? collection;
23
+
24
+ if (retention === undefined) return 'skipped';
25
+
26
+ const col: any = db.collection(collection);
27
+
28
+ try {
29
+ const existing = (await col.listIndexes().toArray())
30
+ .find((entry: any) => entry.name === index);
31
+
32
+ if (retention === false) {
33
+ if (!existing) return 'unchanged';
34
+ await col.dropIndex(index);
35
+ console.log(`🧹 ${label} retention disabled — ${index} dropped`);
36
+ return 'dropped';
37
+ }
38
+
39
+ const parsed = parseDuration(retention);
40
+ if (parsed === null) {
41
+ console.error(`Invalid ${label} retention '${retention}' — expected a duration like '90d' or '24h'`);
42
+ return 'skipped';
43
+ }
44
+ const expireAfterSeconds = Math.round(parsed / 1000);
45
+
46
+ if (!existing) {
47
+ await col.createIndex({ [field]: 1 }, { name: index, expireAfterSeconds, background: true });
48
+ console.log(`🧹 ${label} retention enabled — entries expire after ${retention}`);
49
+ return 'applied';
50
+ }
51
+
52
+ if (existing.expireAfterSeconds === expireAfterSeconds) return 'unchanged';
53
+
54
+ try {
55
+ await db.command({ collMod: collection, index: { name: index, expireAfterSeconds } });
56
+ } catch {
57
+ await col.dropIndex(index);
58
+ await col.createIndex({ [field]: 1 }, { name: index, expireAfterSeconds, background: true });
59
+ }
60
+ console.log(`🧹 ${label} retention updated — entries expire after ${retention}`);
61
+ return 'applied';
62
+ } catch (err: any) {
63
+ console.error(`${label} retention failed (${retention})`, err?.message);
64
+ return 'skipped';
65
+ }
66
+ }
67
+
68
+ export { syncTtlRetention }